diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..62ce405769 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Copy to .env (never commit .env). + +# compose.enhanced.yml (build from source) and compose.image.yml (prebuilt image) +POB2_PORT=8080 +# 127.0.0.1 = this machine only; 0.0.0.0 = reachable from your network. There is no login, so only expose it +# on a network you trust (or use compose.tailscale.yml). +POB2_BIND=127.0.0.1 +# Hostnames/IPs other machines use to reach /mcp (comma-separated); localhost always works. +POB2_ALLOWED_HOSTS= +# compose.image.yml only: image to run +# POB2_IMAGE=ghcr.io/skitty4fingers/pob-enhanced:latest + +# compose.tailscale.yml +# Auth key: https://login.tailscale.com/admin/settings/keys (reusable not needed; state is persisted) +TS_AUTHKEY=tskey-auth-xxxxxxxxxxxx +# Your tailnet DNS name: admin console -> DNS -> "Tailnet name", e.g. tail1234.ts.net +TS_TAILNET=tail1234.ts.net +TS_HOSTNAME=pob2 +# Optional, e.g. --advertise-tags=tag:pob2 (the tag must exist in your ACL policy) +TS_EXTRA_ARGS= diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml deleted file mode 100644 index 1b96f8bfb3..0000000000 --- a/.github/workflows/backport.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Port changes to PoB1 -run-name: "Port PR #${{ github.event.pull_request.number || inputs.pr_number }} - ${{ github.event.pull_request.title || 'Manual dispatch' }}" - -on: - pull_request_target: - types: [closed] - workflow_dispatch: - inputs: - pr_number: - description: PoB2 PR number to port - required: true - type: number - -jobs: - backport: - if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'pob1')) - runs-on: ubuntu-latest - steps: - - name: Determine PR number - run: | - if [ "${{ github.event_name }}" = "pull_request_target" ]; then - echo "PR_NUMBER=${{ github.event.pull_request.number }}" >> "$GITHUB_ENV" - else - echo "PR_NUMBER=${{ github.event.inputs.pr_number }}" >> "$GITHUB_ENV" - fi - - - name: Fetch PR details - id: payload - uses: actions/github-script@v7 - with: - result-encoding: string - script: | - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: process.env.PR_NUMBER - }); - const labels = pr.data.labels.map(l => l.name).join(','); - return JSON.stringify({ - patch_url: pr.data.patch_url, - msg: `Apply changes from ${pr.data.html_url}`, - id: pr.data.number, - title: pr.data.title, - labels, - name: pr.data.user.name || pr.data.user.login, - user: pr.data.user.login, - }); - - - name: Notify PathOfBuilding repo - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.WIRES77_PAT }} - repository: ${{ github.repository_owner }}/PathOfBuilding - event-type: port-changes - client-payload: ${{ steps.payload.outputs.result }} diff --git a/.github/workflows/backport_receive.yml b/.github/workflows/backport_receive.yml deleted file mode 100644 index ccc9aa89b1..0000000000 --- a/.github/workflows/backport_receive.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: Update code with code from PoB1 -run-name: "PR #${{ github.event.client_payload.id }} - ${{ github.event.client_payload.title }}" - -on: - repository_dispatch: - types: - - port-changes - -jobs: - apply-patch: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - ref: 'dev' - - name: Apply patch - env: - PORT_SOURCE_TOKEN: ${{ secrets.WIRES77_PAT }} - PR_NUMBER: ${{ github.event.client_payload.id }} - run: | - set -euo pipefail - - if [ -z "$PORT_SOURCE_TOKEN" ]; then - echo "::error::WIRES77_PAT is not configured" - exit 1 - fi - - PATCH_FILE=$(mktemp) - curl --fail-with-body --location \ - --retry 5 \ - --retry-all-errors \ - --retry-max-time 300 \ - --header "Accept: application/vnd.github.patch" \ - --header "Authorization: Bearer $PORT_SOURCE_TOKEN" \ - --header "X-GitHub-Api-Version: 2026-03-10" \ - "https://api.github.com/repos/PathOfBuildingCommunity/PathOfBuilding/pulls/$PR_NUMBER" \ - --output "$PATCH_FILE" - - if ! grep -Eq '^(From [0-9a-f]{40} Mon Sep 17 00:00:00 2001|diff --git )' "$PATCH_FILE"; then - echo "::error::GitHub returned something other than a patch" - exit 1 - fi - - if ! git apply -v --3way --ignore-whitespace --index "$PATCH_FILE"; then - echo "3-way apply failed, retrying with --reject" - git apply -v --reject --ignore-whitespace --index "$PATCH_FILE" || true - fi - - if [ -z "$(git status --porcelain)" ]; then - echo "::error::Patch produced no changes or rejection files" - exit 1 - fi - - name: Create Pull Request - uses: peter-evans/create-pull-request@v5 - with: - title: "[pob1-port] ${{ github.event.client_payload.title }}" - branch: pob1-pr-${{ github.event.client_payload.id }} - body: | - ${{ github.event.client_payload.msg }} - author: ${{ github.event.client_payload.name || github.event.client_payload.user }} <${{ github.event.client_payload.user }}@users.noreply.github.com> - committer: ${{ github.event.client_payload.name || github.event.client_payload.user }} <${{ github.event.client_payload.user }}@users.noreply.github.com> - commit-message: ${{ github.event.client_payload.msg }} - labels: ${{ github.event.client_payload.labels }} diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml deleted file mode 100644 index a8f252382c..0000000000 --- a/.github/workflows/beta.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Push beta branch -on: - schedule: - - cron: '0 0 * * 5' - push: - branches: - - 'master' - workflow_dispatch: -jobs: - push-beta: - runs-on: ubuntu-22.04 - steps: - - name: Set line endings - run: git config --global core.autocrlf true - - name: Checkout - uses: actions/checkout@v3 - with: - ref: 'dev' - fetch-depth: 0 - - name: Configure bot user - run: | - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - - name: Generate Release notes - run: | - echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token - # Delete existing beta draft if it exists - if gh release view beta >/dev/null 2>&1; then - gh release delete beta --yes - fi - # Create new beta draft release with generated notes - # Make sure the latest tag is correct, even if the current commit is already tagged - LATEST_TAG=$(git describe --tags --abbrev=0) - gh release create beta --title "Beta Release" --draft --generate-notes --notes-start-tag "$LATEST_TAG" - gh release view beta > temp_change.md - - name: Tweak changelogs - id: tweak-changelogs - continue-on-error: true - run: | - # Remove carriage returns to be able to run the script - sed -i 's/\r$//' .github/tweak_changelogs.sh - chmod +x .github/tweak_changelogs.sh - .github/tweak_changelogs.sh beta - # The hash suffix will help identifying if the beta version is up-to-date - - name: Add commit hash suffix to manifest version - if: steps.tweak-changelogs.outcome == 'success' - run: | - sed -i "s/> "$GITHUB_PATH" + - name: Lint + working-directory: python + run: | + uv sync --frozen + uv run ruff check src tests + uv run ruff format --check src tests + - name: Test + working-directory: python + run: uv run pytest -v + + web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run build diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml deleted file mode 100644 index 56fc17cde3..0000000000 --- a/.github/workflows/installer.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Create new installer -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag_name: - description: 'Tag to build' - required: true - default: 'v0.x.x' -env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} -jobs: - release: - runs-on: windows-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - repository: 'PathOfBuildingCommunity/PathOfBuilding-Installer' - ref: 'master' - ssh-key: '${{ secrets.POB_INSTALLER_KEY }}' - - name: Install NSIS - run: | - choco install nsis --yes - echo "C:\Program Files (x86)\NSIS" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Create installer - run: 'python3 make_release.py --game-version 2' - - name: Upload artifact - run: > - gh release upload ${{ github.event.release.tag_name || github.event.inputs.tag_name }} (Get-ChildItem Dist -File).FullName --clobber -R ${{ github.repository }}; diff --git a/.github/workflows/manifest.yml b/.github/workflows/manifest.yml deleted file mode 100644 index 3837088ae9..0000000000 --- a/.github/workflows/manifest.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Update manifest.xml -on: - workflow_dispatch: - inputs: - version: - description: 'Version number to set in manifest.xml' - required: true - default: '0.0.0' -jobs: - push-dev: - runs-on: ubuntu-22.04 - steps: - - name: Set line endings - run: git config --global core.autocrlf true - - name: Checkout - uses: actions/checkout@v3 - with: - ref: 'dev' - fetch-depth: 0 - - name: Configure bot user - run: | - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - - name: Update manifest.xml - run: python3 update_manifest.py --quiet --in-place --set-version ${{ github.event.inputs.version }} - - name: Create Pull Request - uses: peter-evans/create-pull-request@v5 - with: - title: Update manifest.xml to ${{ github.event.inputs.version }} - branch: manifest-${{ github.event.inputs.version }} - commit-message: 'Update manifest.xml to ${{ github.event.inputs.version }}' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000000..33d020eba9 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,52 @@ +name: Publish Docker image + +# Builds the web app image and publishes it to GitHub Container Registry: +# ghcr.io//:latest (default branch) +# ghcr.io//:vX.Y.Z (tags) +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=tag + type=sha,format=short + labels: | + org.opencontainers.image.title=PoB-Enhanced + org.opencontainers.image.description=Path of Building 2 (Path of Exile 2) as a web app with build advisors, REST API and MCP server + - uses: docker/build-push-action@v6 + with: + context: . + file: docker/enhanced.Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2b7bfdf6bc..0000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Release next version -run-name: Release version ${{ inputs.releaseVersion }} -on: - workflow_dispatch: - inputs: - releaseVersion: - description: 'Version number to use for this release' - required: true - default: '0.x.x' - releaseNoteUrl: - description: 'Enter the location of edited release notes to use' - required: false -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - ref: 'dev' - - name: Generate Release notes - if: ${{ github.event.inputs.releaseNoteUrl == '' }} - run: > - echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token; - gh release view $(basename $(gh release create v${{ github.event.inputs.releaseVersion }} --title "Release ${{ github.event.inputs.releaseVersion }}" --draft --generate-notes)) > temp_change.md - - name: Use existing Release notes - if: ${{ github.event.inputs.releaseNoteUrl != '' }} - run: > - echo "${{ secrets.GITHUB_TOKEN }}" | gh auth login --with-token; - gh release view $(basename ${{ github.event.inputs.releaseNoteUrl }}) > temp_change.md - - name: Tweak changelogs - run: | - # Remove carriage returns to be able to run the script - sed -i 's/\r$//' .github/tweak_changelogs.sh - chmod +x .github/tweak_changelogs.sh - .github/tweak_changelogs.sh "v${{ github.event.inputs.releaseVersion }}" - - name: Create Pull Request - uses: peter-evans/create-pull-request@v5 - with: - draft: true - title: Release ${{ github.event.inputs.releaseVersion }} - branch: release-${{ github.event.inputs.releaseVersion }} - body: | - Draft release of ${{ github.event.inputs.releaseVersion }}. - - Edits will be made as necessary to prepare the codebase for release. - commit-message: 'Prepare release ${{ github.event.inputs.releaseVersion }}' diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml deleted file mode 100644 index c6c8cbe421..0000000000 --- a/.github/workflows/spellcheck.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Spell Checker - -on: - push: - branches: [ dev ] - pull_request: - branches: [ dev ] - workflow_dispatch: - inputs: - ref: - description: Branch, tag or SHA to checkout - default: dev - required: false - -jobs: - spellcheck: - runs-on: ubuntu-latest - env: - CONFIG_URL: https://raw.githubusercontent.com/Nightblade/pob-dict/main - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref }} - - - name: Fetch config file and dictionaries - run: - curl --silent --show-error --parallel --remote-name-all - ${{ env.CONFIG_URL }}/cspell.json - ${{ env.CONFIG_URL }}/pob-dict.txt - ${{ env.CONFIG_URL }}/poe-dict.txt - ${{ env.CONFIG_URL }}/ignore-dict.txt - ${{ env.CONFIG_URL }}/extra-en-dict.txt - ${{ env.CONFIG_URL }}/contribs-dict.txt - - - name: Run cspell - uses: streetsidesoftware/cspell-action@v8 - with: - files: '**' # needed as workaround for non-incremental runs (overrides in config still work ok) - config: "cspell.json" - incremental_files_only: ${{ github.event_name != 'workflow_dispatch' }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a489e14008..9d97f9077b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,9 @@ name: Run Tests on: push: - branches: [ dev ] + branches: [ main, dev ] pull_request: - branches: [ dev ] + branches: [ main, dev ] workflow_dispatch: jobs: run_tests: diff --git a/.github/workflows/update-simple-graphic.yml b/.github/workflows/update-simple-graphic.yml deleted file mode 100644 index f9ee4df8a5..0000000000 --- a/.github/workflows/update-simple-graphic.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Update SimpleGraphic DLLs -on: - repository_dispatch: - types: [update-simple-graphic] -jobs: - update: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - ref: 'dev' - - name: Download DLLs - uses: robinraju/release-downloader@v1 - with: - repository: ${{ github.repository_owner }}/PathOfBuilding-SimpleGraphic - tag: ${{ github.event.client_payload.tag }} - fileName: SimpleGraphicDLLs-x64-windows.tar - extract: true - out-file-path: runtime - - name: Create Pull Request - uses: peter-evans/create-pull-request@v5 - with: - title: Update to SimpleGraphic ${{ github.event.client_payload.tag }} - branch: simple-graphic-${{ github.event.client_payload.tag }} - body: | - Update DLLs to SimpleGraphic-${{ github.event.client_payload.tag }} from ${{ github.event.client_payload.release_link }} - commit-message: Update DLLs to SimpleGraphic-${{ github.event.client_payload.tag }} - diff --git a/.gitignore b/.gitignore index 1f73d5eb4f..2bb4f7f4ed 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,7 @@ runtime/SimpleGraphic/Screenshots .emmyrc.json .luarc.json + +# Local secrets (Tailscale auth key etc.) +.env + diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..a4f8d037a8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "pob2": { + "command": "uv", + "args": ["run", "--directory", "python", "pob2-mcp"] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..0a57fedc0b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# PoB2-Enhanced + +A fork of Path of Building 2 (PoE2 build planner, Lua) that adds a headless engine, an MCP server, a REST API and a web UI. +Full docs: `docs/enhanced.md`. + +## Layout +- `src/`, `runtime/`, `spec/`: upstream PoB. **Do not edit.** Keep the fork rebasable on upstream `dev`. + Upstream Lua uses LuaJIT 2026 syntax extensions (`+=`, `..=`, `continue`, `|x| -> expr`). These are valid; do not "fix" them. +- `engine/`: headless worker. `worker.lua` handles JSON-lines RPC over stdio. Handlers are in `engine/pobapi/api/.lua` + and auto-register as `.`. Must run with cwd=`src/`. +- `python/`: `pob2` package (uv). `engine.py` manages the process, `service.py` holds shared operations, + `mcp_server.py` (MCP SDK v2 `MCPServer`), `web/` (FastAPI) and `cli.py`. +- `web/`: React 19 + Vite + Tailwind 4 + TanStack Query. Builds into `python/src/pob2/web/static`. + +## Commands +- Runtime: `scripts/setup-engine.sh` (LuaJIT at commit 2460b3ff + lua-utf8 into ~/.local) +- Python: `cd python && uv run pytest && uv run ruff check src tests && uv run ruff format src tests` +- Web: `cd web && npm run build` (runs a typecheck first) +- Run: `uv run --directory python pob2-web` (UI + REST + MCP at /mcp) or `pob2-mcp` (stdio) +- Worker smoke test: `cd src && echo '{"id":1,"method":"worker.ping"}' | luajit ../engine/worker.lua` + +## Conventions +- Lua: tabs, match upstream style. Return plain tables. Use `util.array()` for lists that may be empty, + `util.fail()` for user errors, and `session.recalc()` after changes. +- New build-changing worker methods must be added to `MUTATING` in `service.py` (drives UI live refresh). +- Python: ruff, line length 120, type hints. Keep MCP tool docstrings short and agent-oriented. diff --git a/README.md b/README.md index 8e4cc222fd..658d26c76c 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,159 @@ -# Path of Building 2 Community -## Welcome to Path of Building 2, an offline build planner for Path of Exile 2! +# PoB-Enhanced -

- Tree tab - Items tab -

+**Path of Building 2 for Path of Exile 2, as a self-hosted web app.** It runs the unmodified Path of Building +calculation engine in a Docker container and adds a browser UI, build advisors, a REST API and an MCP server for +AI agents. -## Download -Head over to the [Releases](https://github.com/PathOfBuildingCommunity/PathOfBuilding-PoE2/releases) page to download the install wizard or portable zip. +![Passive tree with the advisor](docs/images/tree-advisor.png) + +Everything below is measured by PoB's own calculator in **your** build, for **your** Config tab settings. ## Features -* Comprehensive offence + defence calculations: - * Calculate your skill DPS, damage over time, life/mana/ES totals and much more! - * Can factor in auras, buffs, charges, curses, monster resistances and more, to estimate your effective DPS - * Also calculates life/mana reservations - * Shows a summary of character stats in the side bar, as well as a detailed calculations breakdown tab which can show you how the stats were derived - * Supports all skills and support gems, and most passives and item modifiers - * Throughout the program, supported modifiers will show in blue and unsupported ones in red - * Full support for minions - * Support for party play and support builds -* Passive skill tree planner: - * Support for jewels including most radius/conversion and timeless jewels - * Features alternate path tracing (mouse over a sequence of nodes while holding shift, then click to allocate them all) - * Fully integrated with the offence/defence calculations; see exactly how each node will affect your character! - * Can import PathOfExile.com and PoEPlanner.com passive tree links; links shortened with PoEURL.com also work -* Skill planner: - * Add any number of main or supporting skills to your build - * Supporting skills (auras, curses, buffs) can be toggled on and off - * Automatically applies Socketed Gem modifiers from the item a skill is socketed into - * Automatically applies support gems granted by items -* Item planner: - * Add items from in game by copying and pasting them straight into the program! - * Automatically adds quality to non-corrupted items - * Search the trade site for the most impactful items - * Fully integrated with the offence/defence calculations; see exactly how much of an upgrade a given item is! - * Contains a searchable database of all uniques that are currently in game (and some that aren't yet!) - * You can choose the modifier rolls when you add a unique to your build - * Includes all league-specific items and legacy variants - * Features an item crafting system: - * You can select from any of the game's base item types - * You can select prefix/suffix modifiers from lists - * Custom modifiers can be added, with Master and Essence modifiers available - * Also contains a database of rare item templates: - * Allows you to create rare items for your build to approximate the gear you will be using - * Choose which modifiers appear on each item, and the rolls for each modifier, to suit your needs - * Has templates that should cover the majority of builds -* Other features: - * You can import passive tree, items, and skills from existing characters - * Share builds with other users by generating a share code - * Automatic updating; most updates will only take a couple of seconds to apply - -## Changelog -You can find the full version history [here](CHANGELOG.md). - -## Contribute -You can find instructions on how to contribute code and bug reports [here](CONTRIBUTING.md). - -## Licence -[MIT](https://opensource.org/licenses/MIT) - -For 3rd-party licences, see [LICENSE](LICENSE.md). -The licencing information is considered to be part of the documentation. + +**Passive tree** +- The game's own PoE2 tree artwork: node icons, frames, socketed jewels, class and ascendancy art, curved orbits. +- Tree links in the official PoE2 planner format (`pathofexile2.com/game/passive-skill-tree/...`), including + weapon-set and attribute choices. +- **Advisor**: where your next passive point should go (with the path), which point to refund and move, and which + +5 attribute nodes to switch, with reasons that respect your gear and gem attribute requirements. + +**Upgrades** +- **Check before you buy**: paste any item for an upgrade / sidegrade / downgrade verdict across every damage skill, + with warnings for lost totems, Spirit shortfalls, attribute requirements and skills the swap removes. +- **Gear and jewels**: what each item and each mod is worth (dead mods flagged), what to look for on a replacement, + how much room for improvement each slot has, and a weighted pathofexile.com trade search per slot. +- **Gems**: what each support is worth, the best replacement for the weakest support in each skill, gem level and + quality upgrades, and problems like disabled or harmful supports. One click applies a suggestion. +- **Shop**: every unique that fits a slot, measured in your build, with poe.ninja prices, a budget filter and + "best value for money". + +**Spirit and totems.** PoB reports totem damage per totem and doesn't model Ancestral Bond's Spirit cost per totem. +PoB-Enhanced tracks your Spirit budget and the totems you can actually keep up, and every advisor uses **DPS across +all totems**. A weapon that costs you totems shows up as a downgrade. + +![Check before you buy](docs/images/check-item.png) + +**Build guides.** Upload a guide's PoE2 BuildPlanner `.build` file (Maxroll and the in-game planner export these) +to see missing and extra passives, skill and support gaps, gear notes, and the guide's notables ranked by gain per +point. The guide's untaken nodes are outlined on the tree. + +**Your character, kept in sync.** Import a share code, a pobb.in / pastebin / poe.ninja link, or a **poe.ninja +character page** (ladder or profile). The **Update** button re-imports it later and keeps your Config settings. + +**AI agents (MCP).** About 50 tools let Claude or any MCP client load, inspect, what-if and edit builds, using the +same advisors as the UI. The web UI and an agent share one live build. + +| Upgrades: gear | Shop | Guide | +|---|---|---| +| ![Gear advice](docs/images/upgrades-gear.png) | ![Shop](docs/images/shop.png) | ![Guide comparison](docs/images/guide.png) | + +## Run it + +You need [Docker](https://docs.docker.com/get-docker/). Then open **http://localhost:8080**. + +### Option 1: prebuilt image + +```bash +docker run -d --name pob-enhanced --restart unless-stopped -p 8080:8080 ghcr.io/skitty4fingers/pob-enhanced:latest +``` + +Or with Compose (also handles updates): + +```bash +curl -O https://raw.githubusercontent.com/Skitty4fingers/PoB-Enhanced/main/compose.image.yml +docker compose -f compose.image.yml up -d +docker compose -f compose.image.yml pull && docker compose -f compose.image.yml up -d # update +``` + +### Option 2: build from source + +```bash +git clone https://github.com/Skitty4fingers/PoB-Enhanced.git +cd PoB-Enhanced +docker compose -f compose.enhanced.yml up -d --build +``` + +The first build takes a few minutes (it compiles LuaJIT and the web UI). To update: `git pull`, then run the same +command again. + +### Option 3: NAS / home server (TrueNAS SCALE, Unraid, Portainer, ...) + +Create a custom app / container: + +| Setting | Value | +|---|---| +| Image | `ghcr.io/skitty4fingers/pob-enhanced:latest` | +| Port | container `8080` → any host port | +| Environment | `POB2_ALLOWED_HOSTS` = the hostname/IP you browse to, e.g. `nas,192.168.1.20` (needed for MCP) | +| Storage | none (the build lives in memory; re-import or press Update after a restart) | + +### Option 4: without Docker (Linux / macOS / WSL) + +```bash +scripts/setup-engine.sh # builds LuaJIT + lua-utf8, installs Python deps, builds the web UI +export PATH="$HOME/.local/bin:$PATH" +uv run --directory python pob2-web # http://127.0.0.1:8080 +``` + +Requires Python 3.10+, [uv](https://docs.astral.sh/uv/) and Node 22+. The PoE2 tree artwork is already in +`tree-art/`. Details: [docs/enhanced.md](docs/enhanced.md). + +## Use it + +1. **Import** your character: paste a PoB share code, or your poe.ninja character URL + (`https://poe.ninja/poe2/profile///character/`). Press **Update** whenever your character + changes. +2. **Tree**: follow the advisor's NEXT and MOVE markers, or hover any node for its stats and what-if deltas. +3. **Upgrades**: start with **Check item** before buying anything, then work through Gear, Jewels, Gems and Shop. +4. **Guide**: upload a guide's `.build` file to see how far you are from it. + +### Connect an AI agent + +```bash +claude mcp add --transport http pob2 http://localhost:8080/mcp +``` + +For another machine, set `POB2_ALLOWED_HOSTS` to the hostname you use and connect to `http://:8080/mcp`. +The tools are listed in [docs/enhanced.md](docs/enhanced.md#connecting-ai-agents-mcp). + +## Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `POB2_PORT` | `8080` | Host port (Compose files) | +| `POB2_BIND` | `127.0.0.1` | Host address to listen on (Compose files). `0.0.0.0` makes it reachable from your network | +| `POB2_ALLOWED_HOSTS` | (none) | Extra hostnames accepted by `/mcp`, comma-separated. localhost always works | +| `POB2_IMAGE` | `ghcr.io/skitty4fingers/pob-enhanced:latest` | Image for `compose.image.yml` | + +Put them in a `.env` file next to the Compose file (see `.env.example`). + +**Security:** there is no login. Anyone who can reach the port can view and change the loaded build. Keep it on +`127.0.0.1`, a trusted home network, or your tailnet (`compose.tailscale.yml` serves it over Tailscale with HTTPS +and no open ports). + +## Development + +| Path | What | +|---|---| +| `src/`, `runtime/`, `spec/` | Upstream Path of Building 2, unmodified (kept rebasable on upstream) | +| `engine/` | Headless LuaJIT worker: JSON-RPC handlers including the advisors, budget and tree links | +| `python/` | `pob2` package: engine bridge, REST API (FastAPI), MCP server, CLI, poe.ninja/trade helpers | +| `web/` | React 19 + Vite + Tailwind UI | +| `tree-art/` | PoE2 tree artwork as WebP. Regenerate with `scripts/build_tree_art.py` after a tree data update | + +```bash +cd python && uv run pytest && uv run ruff check src tests # tests (engine tests need LuaJIT, or run in Docker) +cd web && npm ci && npm run build # web UI into python/src/pob2/web/static +``` + +More in [docs/enhanced.md](docs/enhanced.md) (architecture, REST API, MCP tools, worker protocol). + +## Credits and licence + +PoB-Enhanced is built on [Path of Building Community (PoE2)](https://github.com/PathOfBuildingCommunity/PathOfBuilding-PoE2), +whose calculation engine does all the maths. Prices and character data come from [poe.ninja](https://poe.ninja); +trade searches open on [pathofexile.com](https://www.pathofexile.com/trade2). Game data and artwork © Grinding Gear +Games. This project is not affiliated with or endorsed by Grinding Gear Games, Path of Building Community or +poe.ninja. + +Licensed under the [MIT licence](LICENSE.md), like Path of Building. diff --git a/compose.enhanced.yml b/compose.enhanced.yml new file mode 100644 index 0000000000..7c448ca668 --- /dev/null +++ b/compose.enhanced.yml @@ -0,0 +1,20 @@ +# Build from source and run: +# docker compose -f compose.enhanced.yml up -d --build -> http://localhost:8080 (MCP: http://localhost:8080/mcp) +# +# Settings (environment or a .env file next to this one, see .env.example): +# POB2_PORT=8080 host port +# POB2_BIND=127.0.0.1 0.0.0.0 to reach it from other machines (there is no login: keep it on a trusted network) +# POB2_ALLOWED_HOSTS= hostnames other machines use for /mcp, e.g. "nas,192.168.1.20" +services: + pob2: + build: + context: . + dockerfile: docker/enhanced.Dockerfile + image: pob-enhanced:local + ports: + - "${POB2_BIND:-127.0.0.1}:${POB2_PORT:-8080}:8080" + environment: + POB2_ALLOWED_HOSTS: ${POB2_ALLOWED_HOSTS:-} + restart: unless-stopped + security_opt: + - no-new-privileges:true diff --git a/compose.image.yml b/compose.image.yml new file mode 100644 index 0000000000..1771da1eb4 --- /dev/null +++ b/compose.image.yml @@ -0,0 +1,19 @@ +# Run the prebuilt image (no build needed): +# docker compose -f compose.image.yml up -d -> http://localhost:8080 (MCP: http://localhost:8080/mcp) +# docker compose -f compose.image.yml pull && docker compose -f compose.image.yml up -d # update +# +# Settings (environment or a .env file next to this one, see .env.example): +# POB2_IMAGE image to run (default: the published GitHub Container Registry image) +# POB2_PORT=8080 host port +# POB2_BIND=127.0.0.1 0.0.0.0 to reach it from other machines (there is no login: keep it on a trusted network) +# POB2_ALLOWED_HOSTS= hostnames other machines use for /mcp, e.g. "nas,192.168.1.20" +services: + pob2: + image: ${POB2_IMAGE:-ghcr.io/skitty4fingers/pob-enhanced:latest} + ports: + - "${POB2_BIND:-127.0.0.1}:${POB2_PORT:-8080}:8080" + environment: + POB2_ALLOWED_HOSTS: ${POB2_ALLOWED_HOSTS:-} + restart: unless-stopped + security_opt: + - no-new-privileges:true diff --git a/compose.tailscale.yml b/compose.tailscale.yml new file mode 100644 index 0000000000..b9ba588080 --- /dev/null +++ b/compose.tailscale.yml @@ -0,0 +1,39 @@ +# PoB2-Enhanced on your tailnet: https://. (MCP at /mcp) +# No host ports are published; only devices on your tailnet can reach it. +# +# cp .env.example .env # fill in TS_AUTHKEY and TS_TAILNET +# docker compose -f compose.tailscale.yml up -d --build +# +# Requires MagicDNS and HTTPS certificates enabled in the Tailscale admin console (DNS page). +services: + tailscale: + image: tailscale/tailscale:stable + hostname: ${TS_HOSTNAME:-pob2} + environment: + TS_AUTHKEY: ${TS_AUTHKEY:-} # optional after first login; state is persisted + TS_HOSTNAME: ${TS_HOSTNAME:-pob2} + TS_EXTRA_ARGS: ${TS_EXTRA_ARGS:-} # e.g. --advertise-tags=tag:pob2 + TS_STATE_DIR: /var/lib/tailscale + TS_SERVE_CONFIG: /config/serve.json + TS_USERSPACE: "true" # no /dev/net/tun or NET_ADMIN needed (works on Docker Desktop) + volumes: + - tailscale-state:/var/lib/tailscale + - ./docker/tailscale:/config:ro + restart: unless-stopped + + pob2: + build: + context: . + dockerfile: docker/enhanced.Dockerfile + network_mode: service:tailscale # share the tailscale container's network namespace + environment: + POB2_ALLOWED_HOSTS: ${TS_HOSTNAME:-pob2}.${TS_TAILNET:?Set TS_TAILNET in .env, e.g. tail1234.ts.net} + command: ["pob2-web", "--host", "127.0.0.1", "--port", "8080"] + depends_on: + - tailscale + restart: unless-stopped + security_opt: + - no-new-privileges:true + +volumes: + tailscale-state: diff --git a/docker/enhanced.Dockerfile b/docker/enhanced.Dockerfile new file mode 100644 index 0000000000..a5fd8e04d0 --- /dev/null +++ b/docker/enhanced.Dockerfile @@ -0,0 +1,47 @@ +# PoB-Enhanced: headless engine + REST API + web UI + MCP endpoint. +# docker build -f docker/enhanced.Dockerfile -t pob2-enhanced . +# docker run --rm -p 8080:8080 pob2-enhanced # web UI + REST + MCP at /mcp +# docker run --rm -i pob2-enhanced pob2-mcp # MCP over stdio +ARG LUAJIT_COMMIT=2460b3ff93a1c955de3d62cfc825de7d68dc272e + +FROM node:22-slim AS web +WORKDIR /repo/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +FROM debian:bookworm-slim AS luajit +ARG LUAJIT_COMMIT +RUN apt-get update && apt-get install -y --no-install-recommends build-essential git ca-certificates \ + && rm -rf /var/lib/apt/lists/* +RUN git clone https://github.com/LuaJIT/LuaJIT /tmp/luajit && cd /tmp/luajit && git checkout "$LUAJIT_COMMIT" \ + && make -j"$(nproc)" PREFIX=/opt/luajit && make install PREFIX=/opt/luajit \ + && ln -sf "$(ls /opt/luajit/bin/luajit-2.1.* | tail -1)" /opt/luajit/bin/luajit +RUN git clone --depth 1 --branch 0.1.6 https://github.com/starwing/luautf8 /tmp/luautf8 \ + && mkdir -p /opt/luajit/lib/lua/5.1 \ + && cc -O2 -fPIC -shared -I/opt/luajit/include/luajit-2.1 /tmp/luautf8/lutf8lib.c -o /opt/luajit/lib/lua/5.1/lua-utf8.so + +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +COPY --from=luajit /opt/luajit /opt/luajit +ENV PATH="/opt/luajit/bin:/app/python/.venv/bin:$PATH" \ + POB2_ROOT=/app \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy +RUN useradd --create-home pob +WORKDIR /app +COPY python/pyproject.toml python/uv.lock python/README.md python/ +RUN uv sync --directory python --frozen --no-dev --no-install-project +COPY --chown=pob src/ src/ +# PoE2 tree artwork, pre-converted to WebP (regenerate with scripts/build_tree_art.py when TreeData changes). +COPY tree-art/ tree-art/ +COPY runtime/lua/ runtime/lua/ +COPY engine/ engine/ +COPY python/ python/ +COPY --from=web /repo/python/src/pob2/web/static python/src/pob2/web/static +RUN uv sync --directory python --frozen --no-dev +USER pob +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/api/health')" +CMD ["pob2-web", "--host", "0.0.0.0", "--port", "8080"] diff --git a/docker/enhanced.Dockerfile.dockerignore b/docker/enhanced.Dockerfile.dockerignore new file mode 100644 index 0000000000..112c438892 --- /dev/null +++ b/docker/enhanced.Dockerfile.dockerignore @@ -0,0 +1,14 @@ +**/node_modules +**/.venv +**/__pycache__ +.git +runtime/* +!runtime/lua +runtime-win32.zip +python/src/pob2/web/static +src/Export +src/Builds +# Tree sprites/textures are only used by the desktop renderer (~350 MB); the web UI uses tree-art/ instead. +src/TreeData/**/*.png +src/TreeData/**/*.jpg +src/TreeData/**/*.zst diff --git a/docker/tailscale/serve.json b/docker/tailscale/serve.json new file mode 100644 index 0000000000..a31dc222c3 --- /dev/null +++ b/docker/tailscale/serve.json @@ -0,0 +1,15 @@ +{ + "TCP": { + "443": { "HTTPS": true } + }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { + "/": { "Proxy": "http://127.0.0.1:8080" } + } + } + }, + "AllowFunnel": { + "${TS_CERT_DOMAIN}:443": false + } +} diff --git a/docs/enhanced.md b/docs/enhanced.md new file mode 100644 index 0000000000..35c93f4e6a --- /dev/null +++ b/docs/enhanced.md @@ -0,0 +1,288 @@ +# PoB-Enhanced: headless engine, MCP server and web UI + +PoB2-Enhanced runs the unmodified Path of Building 2 calculation engine without the Windows desktop UI. It adds: + +| Layer | Path | What it is | +|---|---|---| +| Engine worker | `engine/` | LuaJIT process that boots PoB headless and answers JSON-RPC on stdin/stdout | +| Python bridge | `python/src/pob2/` | Process manager, share-code codec, and a service layer shared by everything below | +| MCP server | `pob2-mcp` | 30+ tools that let AI agents (Claude, etc.) load, inspect, what-if and edit builds | +| REST API + web UI | `pob2-web`, `web/` | FastAPI + React UI (tree, skills, items, config). Also serves MCP at `/mcp` | +| CLI | `pob2` | JSON output for scripting (`jq`, PowerShell `ConvertFrom-Json`) | + +Nothing under `src/` is modified, so upstream updates merge cleanly. + +``` + AI agent ──MCP (stdio or /mcp)──┐ + Browser ──REST /api ────────────┼─► PoBService ─► Engine ─► luajit engine/worker.lua ─► src/ (PoB) + Scripts ──pob2 CLI ─────────────┘ (python) (process, JSON lines) +``` + +When the web UI and an agent use the same `pob2-web` process, they share one live build. The UI polls +`/api/revision` and refreshes as the agent makes changes. + +## Requirements + +- **LuaJIT from commit `2460b3ff` (2026-07-20) or newer.** PoB now uses LuaJIT's syntax extensions (`+=`, `..=`, + `continue`, `|x| -> expr`). Distro packages are older and fail with `'=' expected near '+'`. +- The `lua-utf8` C module. +- Python 3.10+ and [uv](https://docs.astral.sh/uv/). +- Node 22+ (only to build the web UI). + +## Setup + +### Linux / macOS / WSL + +```bash +scripts/setup-engine.sh # builds LuaJIT + lua-utf8 into ~/.local, runs uv sync, builds the web UI +export PATH="$HOME/.local/bin:$PATH" +uv run --directory python pob2-web # http://127.0.0.1:8080 +``` + +### Docker (any OS, including Windows) + +```bash +docker run -d -p 8080:8080 ghcr.io/skitty4fingers/pob-enhanced:latest # prebuilt image (published by CI) +docker compose -f compose.image.yml up -d # same, with Compose +docker compose -f compose.enhanced.yml up -d --build # build from source +docker build -f docker/enhanced.Dockerfile -t pob2-enhanced . +docker run --rm -i pob2-enhanced pob2-mcp # MCP over stdio +``` + +All of these serve http://localhost:8080 with MCP at `/mcp`. The Compose files read `POB2_PORT` (host port), +`POB2_BIND` (`127.0.0.1` by default; `0.0.0.0` to reach it from your network) and `POB2_ALLOWED_HOSTS` from the +shell or a `.env` file (see `.env.example`). `.github/workflows/publish.yml` publishes the image to GitHub Container +Registry on every push to `main` (amd64 and arm64). + +On Windows without WSL or Docker, the desktop app in `runtime/` is unchanged. The headless engine needs a +native `luajit.exe` built from the pinned commit (LuaJIT's own `src\msvcbuild.bat`). Put it next to the `lua-utf8.dll` from `runtime/` and point `POB2_LUAJIT` at it. + +### Tailscale (private HTTPS on your tailnet) + +`compose.tailscale.yml` runs PoB behind a Tailscale sidecar. No ports are published on the host. Only devices on +your tailnet can reach it, at `https://pob2..ts.net`, with a real TLS certificate from Tailscale Serve. + +1. In the [admin console](https://login.tailscale.com/admin/dns) enable **MagicDNS** and **HTTPS Certificates**, + and note the tailnet name (e.g. `tail1234.ts.net`). +2. Create an [auth key](https://login.tailscale.com/admin/settings/keys). +3. `cp .env.example .env`, then set `TS_AUTHKEY` and `TS_TAILNET`. +4. `docker compose -f compose.tailscale.yml up -d --build` + +Connect agents from any tailnet device: + +```bash +claude mcp add --transport http pob2 https://pob2.tail1234.ts.net/mcp +``` + +Notes: +- The node's state is kept in the `tailscale-state` volume, so the auth key is only needed for the first login. + Without a key, `docker compose logs tailscale` prints a login URL. +- Every tailnet member who can reach the node shares the one active build. Use `TS_EXTRA_ARGS=--advertise-tags=tag:pob2` + and an ACL rule to limit who can reach it. +- Funnel (public internet exposure) is explicitly disabled in `docker/tailscale/serve.json`. +- `/mcp` rejects Host headers it doesn't know (DNS-rebinding protection). The compose file sets + `POB2_ALLOWED_HOSTS` to the tailnet name. Set it yourself for any other reverse proxy. + +### Configuration + +| Variable | Default | Purpose | +|---|---|---| +| `POB2_LUAJIT` | `luajit` on `PATH` | LuaJIT executable | +| `POB2_ROOT` | auto-detected | Repository root (contains `src/Launch.lua` and `engine/`) | +| `POB2_ALLOWED_HOSTS` | (none) | Extra hostnames accepted by `/mcp`, comma-separated (localhost is always allowed) | +| `POB2_TREE_ART` | `/tree-art` | Passive tree artwork served at `/tree-art` (see below) | + +### Passive tree artwork + +The web tree draws the game's own PoE2 node icons, frames, class and ascendancy art. These come from the DDS texture +arrays in `src/TreeData//`, converted to WebP by `scripts/build_tree_art.py` and committed in `tree-art/` +(about 22 MB) so a clone or the Docker image needs no conversion step. After a tree data update, regenerate them: + +```bash +uv run --no-project --with zstandard --with texture2ddecoder --with pillow python scripts/build_tree_art.py +``` + +Without the artwork the tree falls back to plain circles. Tree links use the official PoE2 planner format +(`https://pathofexile2.com/game/passive-skill-tree/`, version 7), including weapon-set and attribute choices. + +## Connecting AI agents (MCP) + +**Claude Code:** this repo ships `.mcp.json`, so Claude Code offers the `pob2` server automatically in the repo. +Elsewhere: + +```bash +claude mcp add pob2 -- uv run --directory /path/to/PoB2-Enhanced/python pob2-mcp +claude mcp add --transport http pob2 http://127.0.0.1:8080/mcp # share the web UI's live build +``` + +**Claude Desktop** (`claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "pob2": { "command": "uv", "args": ["run", "--directory", "C:/path/to/PoB2-Enhanced/python", "pob2-mcp"] } + } +} +``` + +or with Docker: `"command": "docker", "args": ["run", "--rm", "-i", "pob2-enhanced", "pob2-mcp"]`. + +`pob2-mcp --http --port 8765` serves streamable HTTP without the web UI. + +### Tools + +| Area | Tools | +|---|---| +| Build | `load_build`, `new_build`, `get_build_overview`, `export_build`, `set_character`, `list_classes` | +| Stats | `get_stats`, `get_stat_summary` | +| Skills | `list_skills`, `add_skill_group`, `set_main_skill`, `set_skill_group_enabled`, `remove_skill_group`, `search_gems` | +| Items | `list_items`, `list_item_slots`, `compare_item`, `equip_item`, `unequip_item`, `parse_item`, `search_uniques`, `get_unique`, `search_item_bases` | +| Tree | `get_passive_tree`, `search_passives`, `compare_passives`, `allocate_passives`, `deallocate_passives`, `import_tree_url`, `export_tree_url` | +| Config | `list_config`, `set_config`, `set_custom_mods` | +| Guide | `load_reference_build`, `compare_to_reference`, `reference_impact`, `apply_reference_tree` | +| Advisor | `get_advice`, `set_attribute_nodes` | +| Upgrades | `gear_advice`, `shopping_advice`, `gem_advice`, `socket_advice`, `set_gem`, `refresh_build` | +| Budget | `check_item`, `get_budget` | + +Resources: `pob://build/code`, `pob://build/xml`. Prompts: `analyze_build`, `optimize_passives`, `optimize_to_reference`. + +The `compare_*` tools run PoB's own what-if calculator, the same one behind the desktop tooltips, and never +change the build. This lets an agent measure a change before applying it. + +## REST API + +`pob2-web` serves an OpenAPI reference at `/docs`. Main routes: + +``` +GET /api/build overview (info, stats, skills, items, non-default config) +POST /api/build/import {source} share code | pobb.in/pastebin/poe.ninja link | XML +POST /api/build/new {className, ascendancy, level} +GET /api/build/export {code, xml} +GET /api/stats?keys=TotalDPS raw output +GET /api/skills POST /api/skills POST /api/skills/{n}/main PATCH|DELETE /api/skills/{n} +GET /api/items POST /api/items/equip|compare|parse DELETE /api/items/{slot} +GET /api/tree GET /api/tree/layout|search|node/{id} POST /api/tree/allocate|deallocate|compare|reset +GET /api/config PATCH /api/config {values} GET|PUT /api/config/custom-mods +GET /api/data/gems|uniques|uniques/{name}|bases +GET|PUT|DELETE /api/reference {content} PoE2 BuildPlanner .build guide to compare against +GET /api/reference/compare?level= passive / skill / gear gaps vs the guide +GET /api/reference/impact?level=&limit= full guide tree stats, next steps ranked by gain per point +POST /api/reference/apply-tree replace the passive tree with the guide's +GET /api/advisor?metric=balanced|dps|ehp next point, point to move, attribute node advice +POST /api/tree/attributes {changes} set +5 Str/Dex/Int on attribute nodes +GET /api/gear/slots | /api/gear/slot?slot=&metric=&league= item/mod worth, what to look for, trade URL +GET /api/gear/uniques?metric=&league=&maxPrice= uniques ranked per slot, poe.ninja prices +GET /api/gems/advice?metric= support worth, swaps, level/quality upgrades, problems +GET /api/sockets?metric=&slot=&limit= rune/soul core/idol worth and best legal replacements per socket +GET /api/sockets/plan?metric=&maxSwaps= combined socket swap plan (greedy, re-measured; nothing is changed) +POST /api/skills/{group}/gems/{index} {name?, level?, quality?, enabled?} +GET /api/build/source POST /api/build/refresh re-import from the source link, keeping config +GET /api/budget Spirit used/free, totems affordable vs cap, DPS across all totems +POST /api/items/check {text, slot?} check before you buy: verdict, every skill, Spirit/totems, warnings +GET /api/revision increments whenever the build changes +``` + +## Reference guides (.build) + +The **Guide** tab (and the `*_reference*` MCP tools) compare the live build with a build guide exported as a PoE2 +BuildPlanner `.build` file (the format the in-game planner and Maxroll use). Import your character first, then upload +the guide. You get: + +- passives the guide takes that you don't, and yours it doesn't, with weapon-set and ascendancy checks +- skill gems and supports (tiers like "Biting Frost I/II" count as the same support), and the guide's gear and notes +- the stat change from respeccing to the guide's whole tree, the guide's notables ranked by DPS% + EHP% per point + (each with the path through guide nodes, one click to allocate), and what each of your extra notables is worth + +Guides are staged by character level (`level_interval`); the comparison uses your build's level unless you pick a +stage. The reference is kept in memory until replaced or the server restarts. + +## Advisor + +The Tree tab's **Advisor** panel (and `get_advice`) answers three questions, measured with PoB's calculator: + +- **Next point** (green, `NEXT`): the first node toward the best target by gain per point. With a guide loaded it + follows the guide's nodes; otherwise it searches notables within 8 points. Optimise for balanced, damage or defence. +- **Move a point** (red, `MOVE`): the allocated end-of-branch node that is cheapest to refund (never disconnects + anything), with the gain from spending it on the next point. With a guide, only nodes the guide doesn't take. +- **Attribute nodes** (amber, `→ Int`): recommended vs current +5 choice for every attribute node. Each swap is + measured, and gear/gem attribute requirements are respected (shortfalls are fixed first, surpluses spent on gain). + Hover a node for the reason; apply one or all. + +## Spirit, totems and "check before you buy" + +PoB reports a totem skill's DPS **per totem**, reserves Ancestral Bond's "75 Spirit per totem" only once, and does not +parse "Your Totem Limit is doubled". On its own it therefore can't show that a weapon with less Spirit costs totems. +PoB2-Enhanced adds a Spirit/totem budget on top of PoB's outputs (`engine/pobapi/api/budget.lua`): + +- totem cap = PoB's limit, doubled for Ancestral Bond while PoB doesn't apply it (detected at runtime) +- totems kept up = min(cap, Spirit available ÷ Spirit per totem), or the Config tab's "# of Summoned Totems" +- **DPS, all totems** = per-totem DPS × totems kept up. Every advisor (tree, gear, shop, gems, guide) scores with it, + so anything that costs totems or Spirit shows up as lost damage, with `totemsDelta` / `spiritDelta` on each row +- what-ifs that replace an item also drop the skills that item granted (e.g. Sacred Flame's Purity of Fire) + +The sidebar shows Spirit in use, what reserves it, totems and the Spirit needed for the next one. **Check item** +(Upgrades tab, Items tab, `check_item`) gives an upgrade/sidegrade/downgrade verdict with warnings for lost totems, +Spirit shortfalls, attribute requirements and removed skills, and each damage skill measured separately. The Items +tab asks for confirmation before equipping an item that has warnings. + +## Upgrades tab + +Everything is measured in your build by PoB (Balanced = DPS% + EHP%, or Damage / Defence only): + +- **Gear / Jewels**: per slot, what the item and each mod is worth (dead mods flagged; mods holding up an attribute + requirement are marked as needed), what to look for on a replacement (PoB's trade stat weighting at average rolls), + the headroom a good replacement would add, and a weighted pathofexile.com trade search. Empty slots are evaluated + with an endgame base (a focus behind a one-handed caster weapon, a quiver behind a bow, otherwise a shield). +- **Gems**: what each support is worth (secondary damage skills on their own DPS, utility skills on the build), the + best replacement for the weakest support per skill (regular, plus the best lineage support), gem level/quality + upgrades, and problems (disabled, duplicate, harmful supports). One click applies a suggestion. +- **Shop**: every unique in PoB's database that fits a slot, measured in your build, with poe.ninja prices for the + chosen league, a budget filter, best gain per divine, and trade links. + +Import a **poe.ninja character page** (`https://poe.ninja/poe2/builds//character//`) or any +build link and the header's **Update** button re-imports it later, keeping your Config tab settings. + +## CLI + +```bash +pob2 overview -b +pob2 stats -b build.xml --keys TotalDPS Life +pob2 call tree.search '{"query": "spell damage"}' -b +pob2 decode > build.xml ; pob2 encode build.xml +pob2 methods +``` + +```powershell +uv run --directory python pob2 stats -b $code --keys TotalDPS | ConvertFrom-Json +``` + +## Worker protocol + +`luajit ../engine/worker.lua`, run with the working directory set to `src/`: + +``` +← {"event":"ready","methods":[...]} +→ {"id":1,"method":"tree.search","params":{"query":"spell damage"}} +← {"id":1,"result":[...]} or {"id":1,"error":{"message":"...","code":"bad_request"}} +``` + +PoB's console output goes to stderr. Error codes: `bad_request`, `not_found`, `method_not_found`, `internal`. + +### Adding a method + +1. Add a function to the right module in `engine/pobapi/api/` (`build`, `stats`, `skills`, `items`, `tree`, + `config`, `data`). It is registered automatically as `.`. Use `util.fail(msg)` for user + errors and `session.recalc()` after changing the build. +2. If it changes the build, add it to `MUTATING` in `python/src/pob2/service.py`. +3. Expose it as an MCP tool in `mcp_server.py` and/or a route in `web/routes.py`. +4. Add a test in `python/tests/test_engine.py`. + +## Development + +```bash +cd python && uv run pytest && uv run ruff check src tests && uv run ruff format src tests +cd web && npm run dev # Vite on :5173, proxies /api and /mcp to pob2-web on :8080 +uv run --directory python pob2-web --reload +``` + +Upstream PoB tests (busted) still run through `docker compose up` as described in `CONTRIBUTING.md`. diff --git a/docs/images/check-item.png b/docs/images/check-item.png new file mode 100644 index 0000000000..100a863557 Binary files /dev/null and b/docs/images/check-item.png differ diff --git a/docs/images/guide.png b/docs/images/guide.png new file mode 100644 index 0000000000..ea3545016b Binary files /dev/null and b/docs/images/guide.png differ diff --git a/docs/images/shop.png b/docs/images/shop.png new file mode 100644 index 0000000000..c3bd2b1381 Binary files /dev/null and b/docs/images/shop.png differ diff --git a/docs/images/tree-advisor.png b/docs/images/tree-advisor.png new file mode 100644 index 0000000000..00be2100ee Binary files /dev/null and b/docs/images/tree-advisor.png differ diff --git a/docs/images/upgrades-gear.png b/docs/images/upgrades-gear.png new file mode 100644 index 0000000000..287c0cd1c4 Binary files /dev/null and b/docs/images/upgrades-gear.png differ diff --git a/engine/pobapi/api/advisor.lua b/engine/pobapi/api/advisor.lua new file mode 100644 index 0000000000..421937c910 --- /dev/null +++ b/engine/pobapi/api/advisor.lua @@ -0,0 +1,372 @@ +-- advisor.*: where the next passive point should go, which allocated point to move, and attribute node choices. +-- Follows a reference guide when one is passed (params.reference), otherwise searches nearby notables. +local util = require("pobapi.util") +local session = require("pobapi.session") +local reference = require("pobapi.api.reference") + +local M = { } + +local ATTRIBUTES = { "Strength", "Dexterity", "Intelligence" } +local SHORT = { Strength = "Str", Dexterity = "Dex", Intelligence = "Int" } +local MIN_GAIN = 0.05 -- percent; smaller changes are noise + +local METRICS = { + balanced = function(r) return r.dpsPercent + r.ehpPercent end, + dps = function(r) return r.dpsPercent end, + ehp = function(r) return r.ehpPercent end, +} + +local function nodeRef(node) + return { id = node.id, name = node.dn, type = node.type, ascendancy = node.ascendancyName } +end + +local function fmt(v, unit) + return string.format("%s%.1f%s", v >= 0 and "+" or "", v, unit) +end + +-- "+1.2% DPS, +6 ES" style summary of a change. +local function describe(r) + local parts = { } + if math.abs(r.dpsPercent) >= MIN_GAIN then parts[#parts + 1] = fmt(r.dpsPercent, "% DPS") end + if math.abs(r.ehpPercent) >= MIN_GAIN then parts[#parts + 1] = fmt(r.ehpPercent, "% EHP") end + if math.abs(r.lifeDelta) >= 1 then parts[#parts + 1] = string.format("%+d Life", math.floor(r.lifeDelta + 0.5)) end + if math.abs(r.esDelta) >= 1 then parts[#parts + 1] = string.format("%+d ES", math.floor(r.esDelta + 0.5)) end + return #parts > 0 and table.concat(parts, ", ") or "no measurable change" +end + +-- What refunding a node does, phrased from the player's side. +local function refundEffect(r) + if r.score >= MIN_GAIN then + return "refunding it actually helps (" .. describe(r) .. ")" + elseif r.score > -MIN_GAIN and math.abs(r.lifeDelta) < 1 and math.abs(r.esDelta) < 1 then + return "refunding it costs nothing measurable" + end + local loss = { dpsPercent = -r.dpsPercent, ehpPercent = -r.ehpPercent, lifeDelta = -r.lifeDelta, esDelta = -r.esDelta } + return "refunding it costs " .. describe(loss):gsub("%+", "") +end + +-- Nodes in `set` ordered so each touches the tree (or an earlier one) when allocated. +local function allocationOrder(set) + local order, seen, queue, head = { }, { }, { }, 1 + for node in pairs(set) do + for _, other in ipairs(node.linked or { }) do + if other.alloc and not set[other] and not seen[node] + and (not other.ascendancyName or other.ascendancyName == node.ascendancyName) then + seen[node] = true + queue[#queue + 1] = node + end + end + end + table.sort(queue, function(a, b) return a.id < b.id end) + while head <= #queue do + local node = queue[head] + head = head + 1 + order[#order + 1] = node + for _, other in ipairs(node.linked or { }) do + if set[other] and not seen[other] then + seen[other] = true + queue[#queue + 1] = other + end + end + end + return order +end + +function M.plan(params) + local b = session.build() + local spec = b.spec + local score = METRICS[params.metric or "balanced"] or METRICS.balanced + local limit = tonumber(params.limit) or 10 + local calcFunc, baseOutput = b.calcsTab:GetMiscCalculator() + local base = reference._headline(baseOutput) + local function measure(override, points) + local h = reference._headline(calcFunc(override)) + local r = { + points = points, + dpsPercent = reference._pct(base.dps, h.dps), + ehpPercent = reference._pct(base.ehp, h.ehp), + lifeDelta = h.life - base.life, + esDelta = h.es - base.es, + } + r.score = score(r) + reference._budgetDeltas(r, base, h) + return r + end + + local guide, level + local wanted = { } + if type(params.reference) == "table" then + local ref + ref, level = reference._getReference(params) + level = level or b.characterLevel + guide = { name = ref.name, level = level } + wanted = reference._resolvePassives(spec, ref, level) + end + + local granted = { } + for _, id in pairs(spec.allocExtendedNodes or { }) do + granted[id] = true + end + + -- Candidate targets and the route to each. + local candidates = { } + if guide then + local missing = { } + for node in pairs(wanted) do + if not node.alloc then + missing[node] = true + end + end + for node in pairs(missing) do + if node.type == "Notable" or node.type == "Keystone" or node.type == "Socket" then + local path = reference._routeTo(node, missing) + if path then + candidates[#candidates + 1] = { target = node, path = path } + end + end + end + else + local maxPath = tonumber(params.maxPath) or 8 + for _, node in pairs(spec.nodes) do + if not node.alloc and (node.type == "Notable" or node.type == "Keystone") and node.path and #node.path > 0 + and #node.path <= maxPath and (not node.ascendancyName or node.ascendancyName == spec.curAscendClassName) then + local set = { } + for _, pathNode in ipairs(node.path) do + set[pathNode] = true + end + candidates[#candidates + 1] = { target = node, path = allocationOrder(set) } + end + end + table.sort(candidates, function(a, c) return #a.path < #c.path end) + while #candidates > 150 do + table.remove(candidates) + end + end + + local steps = util.array() + for _, cand in ipairs(candidates) do + local set, ids = { }, util.array() + for _, node in ipairs(cand.path) do + set[node] = true + ids[#ids + 1] = node.id + end + local r = measure({ addNodes = set }, #cand.path) + r.perPoint = r.score / #cand.path + r.target = nodeRef(cand.target) + r.path = ids + r.first = nodeRef(cand.path[1]) + r.inGuide = guide ~= nil + if guide or r.score >= MIN_GAIN then + steps[#steps + 1] = r + end + end + table.sort(steps, function(a, c) return a.perPoint > c.perPoint end) + + -- Points to move: allocated tips (refunding them disconnects nothing), cheapest first. + local refunds = util.array() + for _, node in pairs(spec.allocNodes) do + if not reference._isStart(node) and not node.ascendancyName and node.id < 65536 and not granted[node.id] + and not wanted[node] and (#(node.depends or { }) <= 1) then + local r = measure({ removeNodes = { [node] = true } }, 1) + r.node = nodeRef(node) + r.notInGuide = guide ~= nil + r.reason = (guide and ((node.dn or "This node") .. " isn't in the guide; ") or ((node.dn or "This node") .. ": ")) + .. refundEffect(r) .. "." + refunds[#refunds + 1] = r + end + end + table.sort(refunds, function(a, c) return a.score > c.score end) + + local nextStep = steps[1] + local move + local refund = refunds[1] + if nextStep and refund then + local net = nextStep.perPoint + refund.score + if guide or net >= MIN_GAIN then + move = { refund = refund, allocate = nextStep, netPerPoint = net } + move.reason = string.format("%s%s; spend the point toward %s instead (%s per point).", + refund.node.name or "This node", guide and " isn't in the guide, and " .. refundEffect(refund) or ": " .. refundEffect(refund), + nextStep.target.name, fmt(nextStep.perPoint, "%")) + end + end + if nextStep then + nextStep.reason = string.format("First point toward %s (%d point%s): %s in total.", nextStep.target.name, + nextStep.points, nextStep.points == 1 and "" or "s", describe(nextStep)) + end + while #steps > limit do table.remove(steps) end + while #refunds > limit do table.remove(refunds) end + + return { + mode = guide and "guide" or "free", + guide = guide, + metric = params.metric or "balanced", + base = base, + next = nextStep, + steps = steps, + refunds = refunds, + move = move, + attributes = M._attributes(spec, calcFunc, baseOutput, measure), + } +end + +-- Attribute nodes: the measured value of each +5 swap, constrained by gear/gem attribute requirements. +function M._attributes(spec, calcFunc, baseOutput, measure) + local nodes, unset = { }, { } + for id, node in pairs(spec.allocNodes) do + local treeNode = spec.tree.nodes[id] + if treeNode and treeNode.isAttribute and treeNode.options then + nodes[#nodes + 1] = node + if not SHORT[node.dn] then + unset[node] = true -- allocated without a choice: gives nothing yet + end + end + end + table.sort(nodes, function(a, c) return a.id < c.id end) + local margin, totals = { }, { } + for _, attr in ipairs(ATTRIBUTES) do + local short = SHORT[attr] + totals[attr] = { value = baseOutput[short] or 0, required = baseOutput["Req" .. short] or 0 } + margin[attr] = totals[attr].value - totals[attr].required + end + if #nodes == 0 then + return { totals = totals, nodes = util.array(), changes = 0 } + end + + local function altNode(id, attr) + local treeNode = spec.tree.nodes[id] + for _, option in ipairs(treeNode.options) do + if (option.dn or option.name) == attr then + local newNode = copyTableSafe(treeNode, false, true) + spec:ReplaceNode(newNode, option) + spec.tree:ProcessStats(newNode) + return newNode + end + end + end + + -- One measurement per (from, to) pair; every attribute node gives the same +5. + local swap = { } + for _, from in ipairs(ATTRIBUTES) do + local rep + for _, node in ipairs(nodes) do + if node.dn == from and (node.allocMode or 0) == 0 then + rep = node + break + end + end + rep = rep or (function() for _, node in ipairs(nodes) do if node.dn == from then return node end end end)() + if rep then + swap[from] = { } + for _, to in ipairs(ATTRIBUTES) do + local alt = to ~= from and altNode(rep.id, to) + if alt then + swap[from][to] = measure({ removeNodes = { [rep] = true }, addNodes = { [alt] = true } }, 0) + end + end + end + end + + local plan = { } -- node -> { to, kind, r } + local left = { } + for _, attr in ipairs(ATTRIBUTES) do left[attr] = margin[attr] end + -- 0. Unassigned attribute nodes: cover a shortfall first, otherwise take the best measured choice. + for _, node in ipairs(nodes) do + if unset[node] then + local best, bestR + for _, attr in ipairs(ATTRIBUTES) do + local alt = altNode(node.id, attr) + if alt then + local r = measure({ removeNodes = { [node] = true }, addNodes = { [alt] = true } }, 0) + local short = left[attr] < 0 + if not best or (short and left[best] >= 0) or ((short == (left[best] < 0)) and r.score > bestR.score) then + best, bestR = attr, r + end + end + end + if best then + plan[node] = { to = best, kind = "unset", r = bestR } + left[best] = left[best] + 5 + end + end + end + local function assign(from, to, kind) + for _, node in ipairs(nodes) do + if not plan[node] and node.dn == from then + plan[node] = { to = to, kind = kind } + left[from] = left[from] - 5 + left[to] = left[to] + 5 + return true + end + end + end + -- 1. Cover attribute shortfalls (unusable gear or gems), taking from the cheapest surplus. + for _, need in ipairs(ATTRIBUTES) do + while left[need] < 0 do + local best, bestScore + for _, from in ipairs(ATTRIBUTES) do + if from ~= need and left[from] >= 5 and swap[from] and swap[from][need] then + local s = swap[from][need].score + if not bestScore or s > bestScore then best, bestScore = from, s end + end + end + if not best or not assign(best, need, "requirement") then break end + end + end + -- 2. Swaps that measurably help, as far as requirements allow. + local pairs_ = { } + for from, targets in pairs(swap) do + for to, r in pairs(targets) do + if r.score >= MIN_GAIN then pairs_[#pairs_ + 1] = { from = from, to = to, r = r } end + end + end + table.sort(pairs_, function(a, c) return a.r.score > c.r.score end) + for _, p in ipairs(pairs_) do + while left[p.from] >= 5 and assign(p.from, p.to, "gain") do end + end + + local list, changes = util.array(), 0 + for _, node in ipairs(nodes) do + local current = node.dn + local entry = { id = node.id, current = current, recommended = current, weaponSet = node.allocMode or 0 } + local p = plan[node] + local t = totals[current] + if p and p.kind == "unset" then + changes = changes + 1 + local r = p.r + entry.current = "unset" + entry.recommended = p.to + entry.dpsPercent, entry.ehpPercent, entry.lifeDelta, entry.esDelta = r.dpsPercent, r.ehpPercent, r.lifeDelta, r.esDelta + entry.reason = string.format("No attribute chosen yet, so this node gives nothing. Choose +5 %s: %s.", SHORT[p.to], describe(r)) + elseif p then + changes = changes + 1 + local r = swap[current][p.to] + entry.recommended = p.to + entry.dpsPercent, entry.ehpPercent, entry.lifeDelta, entry.esDelta = r.dpsPercent, r.ehpPercent, r.lifeDelta, r.esDelta + if p.kind == "requirement" then + local need = totals[p.to] + entry.reason = string.format("Switch +5 %s to +5 %s: you have %d %s but your gear and gems need %d. (%s)", + SHORT[current], SHORT[p.to], need.value, p.to, need.required, describe(r)) + else + entry.reason = string.format("Switch +5 %s to +5 %s: %s. %s stays at or above the %d your gear and gems need.", + SHORT[current], SHORT[p.to], describe(r), current, t.required) + end + else + -- Explain why it stays. + local bestTo, bestR + for to, r in pairs(swap[current] or { }) do + if not bestR or r.score > bestR.score then bestTo, bestR = to, r end + end + if bestR and bestR.score >= MIN_GAIN then + entry.reason = string.format("Keep: +5 %s would give %s, but %s is needed (%d, gear and gems need %d).", + SHORT[bestTo], describe(bestR), current, t.value, t.required) + entry.blockedBy = "requirement" + else + entry.reason = string.format("Keep: +5 %s is already the best choice here.", SHORT[current]) + end + end + list[#list + 1] = entry + end + return { totals = totals, nodes = list, changes = changes } +end + +return M diff --git a/engine/pobapi/api/budget.lua b/engine/pobapi/api/budget.lua new file mode 100644 index 0000000000..47ace69b0a --- /dev/null +++ b/engine/pobapi/api/budget.lua @@ -0,0 +1,442 @@ +-- budget.*: Spirit and totem budget, effective DPS across all totems, and "check before you buy" item verdicts. +-- +-- Why this exists: PoB reports a totem skill's DPS per totem, reserves Ancestral Bond's "75 Spirit per totem" only +-- once (per skill instance), and does not parse "Your Totem Limit is doubled". A weapon that drops Spirit can +-- therefore cost totems without PoB showing any damage loss. Everything here works from PoB's own outputs. +local util = require("pobapi.util") +local session = require("pobapi.session") + +local M = { } + +local doublingParsed +-- True when PoB itself applies "Your Totem Limit is doubled" (then we must not double again). +local function doublingHandledByPoB() + if doublingParsed == nil then + local list = modLib.parseMod("Your Totem Limit is doubled") + doublingParsed = type(list) == "table" and #list > 0 + end + return doublingParsed +end + +-- How a totem skill's limit is made up: `extra` is the BASE ActiveTotemLimit from sources other than skill gems +-- (passives, ascendancy, items), and `overridden` is set when a gem OVERRIDEs the limit. Spell Totem's data does +-- that (a fixed 2, "Should fix to take from active part of gem"), which drops every extra such as Oracle's +-- Unnamed Heartwood (+1 to maximum number of Summoned Totems). +local function totemLimitParts(skill) + local res = { extra = 0 } + if not skill.skillModList or not skill.skillCfg then + return res + end + res.overridden = skill.skillModList:Override(skill.skillCfg, "ActiveTotemLimit", "ActiveBallistaLimit") ~= nil or nil + for _, row in ipairs(skill.skillModList:Tabulate("BASE", skill.skillCfg, "ActiveTotemLimit", "ActiveBallistaLimit")) do + local source = row.mod and row.mod.source or "" + if not source:match("^Skill:") then + res.extra = res.extra + (row.value or 0) + end + end + return res +end + +-- Per-build facts that what-if outputs don't carry: Spirit cost per totem and how PoB counted it. +function M._context(b) + b = b or session.build() + local env = b.calcsTab.mainEnv + local ctx = { ancestralBond = false, cost = nil, pobCount = 1, skills = { }, totemGroups = { }, totemLimits = { }, mainGroup = b.mainSocketGroup } + if not env or not env.player then + return ctx + end + -- Socket groups whose skills are cast by totems (PoB reports a totem limit for every main skill). + local groupIndex = { } + for i, group in ipairs(b.skillsTab.socketGroupList) do + groupIndex[group] = i + end + ctx.ancestralBond = env.player.modDB:Flag(nil, "AncestralBond") or false + ctx.configuredTotems = env.configInput and tonumber(env.configInput.TotemsSummoned) or nil + for _, skill in ipairs(env.player.activeSkillList or { }) do + local gi = skill.socketGroup and groupIndex[skill.socketGroup] + if gi and ((skill.skillFlags and skill.skillFlags.totem) or (skill.skillTypes and skill.skillTypes[SkillType.SummonsTotem])) then + ctx.totemGroups[gi] = true + end + if gi then + -- A meta gem's group holds the totem (Spell Totem) and the skill it casts (Grim Pillars); the override + -- sits on the cast skill, which is not flagged as a totem skill itself. + local parts, seen = totemLimitParts(skill), ctx.totemLimits[gi] + if seen then + parts.overridden = parts.overridden or seen.overridden + parts.extra = math.max(parts.extra, seen.extra) + end + ctx.totemLimits[gi] = parts + end + local reserved = skill.skillData and skill.skillData.SpiritReservedBase + if reserved and reserved > 0 then + local name = skill.activeEffect and skill.activeEffect.grantedEffect and skill.activeEffect.grantedEffect.name or "?" + local count = skill.activeEffect and skill.activeEffect.srcInstance and skill.activeEffect.srcInstance.count or 1 + local isTotem = skill.skillTypes and skill.skillTypes[SkillType.SummonsTotem] and ctx.ancestralBond + ctx.skills[#ctx.skills + 1] = { name = name, spirit = reserved, totem = isTotem or nil } + if isTotem then + ctx.cost = math.max(ctx.cost or 0, reserved / math.max(count, 1)) + ctx.pobCount = math.max(count, 1) + end + end + end + return ctx +end + +-- Spirit and totems for one calc output (base or what-if); `group` is the main socket group it was calculated for. +function M._summarize(output, ctx, group) + local res = { + spirit = output.Spirit or 0, + unreserved = output.SpiritUnreserved or 0, + } + res.reserved = res.spirit - res.unreserved + local limit = output.ActiveTotemLimit + group = group or ctx.mainGroup + if limit and limit > 0 and ctx.totemGroups[group] then + -- Split the limit into the skill's own base and the extras (passives, ascendancy, items). An OVERRIDE + -- (Spell Totem) hides the extras from PoB's number, so they are added back here. + local parts = ctx.totemLimits[group] or { extra = 0 } + local extra = parts.extra or 0 + local base = parts.overridden and limit or math.max(limit - extra, 0) + if ctx.ancestralBond and not doublingHandledByPoB() then + -- The order of "Totem Limit is doubled" and flat additions is not documented. In game, Spell Totem (2) + -- with Unnamed Heartwood (+1) and Ancestral Bond places 5 = 2 x 2 + 1, so only the base is doubled. + base = base * 2 + end + local cap = base + extra + -- Config "# of Summoned Totems" may say more than PoB works out (e.g. mods it does not parse), and fewer. + local wanted = cap + if ctx.configuredTotems and ctx.configuredTotems > 0 then + wanted = ctx.configuredTotems + cap = math.max(cap, wanted) + end + res.totemCap = cap + res.totems = wanted + if ctx.cost and ctx.cost > 0 then + -- PoB already subtracted one totem's worth (pobCount); add it back and divide by the per-totem cost. + local affordable = math.floor((res.unreserved + ctx.cost * ctx.pobCount) / ctx.cost + 1e-9) + res.totemCost = ctx.cost + res.totemsAffordable = math.max(0, affordable) + res.totems = math.min(wanted, res.totemsAffordable) + res.spiritLimited = res.totemsAffordable < wanted or nil + end + res.perTotemDps = math.max(output.TotalDPS or 0, output.CombinedDPS or 0) + end + if res.unreserved < 0 then + res.spiritShort = -res.unreserved + end + return res +end + +-- Main-skill DPS counting every totem you can actually keep up (unchanged for non-totem skills and Full DPS). +function M._effectiveDps(output, summary) + local full = output.FullDPS or 0 + if full > 0 then + return full + end + local dps = math.max(output.TotalDPS or 0, output.CombinedDPS or 0) + if summary.totems then + dps = dps * summary.totems + end + return dps +end + +-- Run fn with the skills granted by the item in `slotName` switched off: they disappear when that item is replaced +-- (e.g. Sacred Flame's Purity of Fire), but PoB's what-if calculator keeps them unless told otherwise. +function M._withoutGrantedSkills(b, slotName, fn) + local disabled = { } + for _, group in ipairs(b.skillsTab.socketGroupList) do + if group.sourceItem and group.slot == slotName and group.enabled ~= false then + group.enabled = false + disabled[#disabled + 1] = group + end + end + local ok, res = pcall(fn) + for _, group in ipairs(disabled) do + group.enabled = true + end + if not ok then + error(res, 0) + end + return res +end + +-- A misc calculator whose item replacements also drop the replaced item's granted skills. +function M._calculator(b) + local rawCalc, baseOutput = b.calcsTab:GetMiscCalculator() + local function calc(override, ...) + if override and override.repSlotName and override.repItem then + local args = { ... } + return M._withoutGrantedSkills(b, override.repSlotName, function() return rawCalc(override, unpack(args)) end) + end + return rawCalc(override, ...) + end + return calc, baseOutput +end + +local ATTRIBUTES = { Str = "Strength", Dex = "Dexterity", Int = "Intelligence" } + +-- Attributes that fall below what gear and gems need in `out` (they were met in `base`). +function M._brokenRequirements(base, out) + local res = util.array() + for short, long in pairs(ATTRIBUTES) do + if (base[short] or 0) >= (base["Req" .. short] or 0) and (out[short] or 0) < (out["Req" .. short] or 0) then + res[#res + 1] = { attribute = long, have = out[short] or 0, need = out["Req" .. short] or 0 } + end + end + return res +end + +-- Before/after budget for a what-if, with plain-language warnings. +function M._compare(baseOutput, newOutput, ctx) + ctx = ctx or M._context() + local before, after = M._summarize(baseOutput, ctx), M._summarize(newOutput, ctx) + local res = { + before = before, + after = after, + effectiveBefore = M._effectiveDps(baseOutput, before), + effectiveAfter = M._effectiveDps(newOutput, after), + warnings = util.array(), + } + if before.totems and after.totems and after.totems < before.totems then + res.warnings[#res.warnings + 1] = ("Totems %d → %d: Spirit %d → %d (each totem reserves %d Spirit)"):format( + before.totems, after.totems, before.spirit, after.spirit, after.totemCost or ctx.cost or 0) + end + if after.spiritShort and not before.spiritShort then + res.warnings[#res.warnings + 1] = ("Spirit short by %d: some reserving skills would switch off"):format(after.spiritShort) + end + for _, r in ipairs(M._brokenRequirements(baseOutput, newOutput)) do + res.warnings[#res.warnings + 1] = ("%s %d below the %d your gear and gems need"):format(r.attribute, r.have, r.need) + end + return res +end + +-- Current budget with the Spirit reservation breakdown. +function M.get() + local b = session.build() + local ctx = M._context(b) + local output = b.calcsTab.mainOutput + local summary = M._summarize(output, ctx) + summary.effectiveDps = M._effectiveDps(output, summary) + summary.ancestralBond = ctx.ancestralBond or nil + -- Totems stay out whatever the main skill is: when it isn't the totem skill, count them via the totem group. + local spiritTotems, totemCost = summary.totems, summary.totemCost + if not spiritTotems and ctx.cost and ctx.cost > 0 then + local groups = { } + for gi in pairs(ctx.totemGroups) do groups[#groups + 1] = gi end + table.sort(groups) + if groups[1] then + local calcFunc = M._calculator(b) + local s = M._summarize(calcFunc({ mainSocketGroup = groups[1] }), ctx, groups[1]) + spiritTotems, totemCost = s.totems, s.totemCost + end + end + summary.reservations = util.array() + for _, s in ipairs(ctx.skills) do + if s.totem and spiritTotems then + summary.reservations[#summary.reservations + 1] = { name = s.name .. " totems", spirit = (totemCost or 0) * spiritTotems, totem = true, count = spiritTotems } + else + summary.reservations[#summary.reservations + 1] = { name = s.name, spirit = s.spirit } + end + end + if spiritTotems then + -- Spirit actually in use once every affordable totem is out. + summary.free = summary.spirit - (summary.reserved - (totemCost or 0) * ctx.pobCount) - (totemCost or 0) * spiritTotems + else + summary.free = summary.unreserved + end + local main = b.calcsTab.mainEnv and b.calcsTab.mainEnv.player and b.calcsTab.mainEnv.player.mainSkill + local ge = main and main.activeEffect and main.activeEffect.grantedEffect + summary.skill = ge and ge.name or nil + summary.ehp = output.TotalEHP or 0 + summary.life = output.Life or 0 + summary.es = output.EnergyShield or 0 + summary.resources = M._resources(output) + summary.defence = { + armour = output.Armour or 0, + evasion = output.Evasion or 0, + block = output.EffectiveBlockChance or output.BlockChance or 0, + lifeRegen = output.LifeRegenRecovery or 0, + fire = output.FireResist or 0, + cold = output.ColdResist or 0, + lightning = output.LightningResist or 0, + chaos = output.ChaosResist or 0, + maxHit = output.PhysicalMaximumHitTaken or 0, + } + return summary +end + +-- Resources the character has or the main skill spends, besides Spirit (reported separately). +function M._resources(output) + local res = util.array() + local function add(key, label, total, extra) + local cost = output[key .. "PerSecondCost"] + if (total or 0) > 0 or (cost or 0) > 0 then + local r = extra or { } + r.key, r.label, r.total = key, label, total or 0 + r.costPerSecond = (cost or 0) > 0 and cost or nil + res[#res + 1] = r + end + end + add("Mana", "Mana", output.Mana, { unreserved = output.ManaUnreserved, regen = output.ManaRegenRecovery }) + add("Rage", "Rage", output.MaximumRage) + add("Ward", "Runic Ward", output.Ward, { regen = output.WardRegenRecovery }) + -- Life and ES appear here only when the main skill spends them (they are shown with EHP anyway). + for _, key in ipairs({ "Life", "ES" }) do + local cost = output[key .. "PerSecondCost"] + if (cost or 0) > 0 then + res[#res + 1] = { key = key, label = key == "ES" and "Energy Shield" or "Life", total = key == "ES" and output.EnergyShield or output.Life, costPerSecond = cost } + end + end + return res +end + +local function slotFor(b, item, wanted) + if wanted then + if not b.itemsTab.slots[wanted] then + util.fail("Unknown slot: " .. tostring(wanted)) + end + return { wanted } + end + local primary = item:GetPrimarySlot() + -- Upstream only maps shields and quivers to the off-hand; a Focus comes back as "Focus", which is not a slot. + if item.type == "Focus" then + primary = "Weapon 2" + end + if not b.itemsTab.slots[primary] then + util.fail(("No slot for %s items (%s)"):format(tostring(item.type), tostring(primary))) + end + if primary == "Ring 1" then + return { "Ring 1", "Ring 2" } + end + if item.type == "Jewel" then + local res = { } + for _, slot in ipairs(b.itemsTab.orderedSlots) do + if slot.nodeId and b.spec.allocNodes[slot.nodeId] then + res[#res + 1] = slot.slotName + end + end + return res + end + return { primary } +end + +-- "Check before you buy": every damage skill (counting totems), Spirit/totems, defences, requirements, verdict. +function M.check_item(params) + local b = session.build() + local item = new("Item"):Item(util.require(params, "text", "string")) + if not item.base then + util.fail("Could not parse item (unknown base type)") + end + item:NormaliseQuality() + item:BuildModList() + local ctx = M._context(b) + local calcFunc, baseOutput = M._calculator(b) + + -- Pick the slot where it does best (e.g. the weaker ring, the weakest jewel). + local best + for _, slotName in ipairs(slotFor(b, item, params.slot)) do + local out = calcFunc({ repSlotName = slotName, repItem = item }) + local cmp = M._compare(baseOutput, out, ctx) + local score = cmp.effectiveBefore > 0 and (cmp.effectiveAfter / cmp.effectiveBefore - 1) or 0 + score = score + ((baseOutput.TotalEHP or 0) > 0 and ((out.TotalEHP or 0) / baseOutput.TotalEHP - 1) or 0) + if not best or score > best.score then + best = { slot = slotName, out = out, cmp = cmp, score = score } + end + end + if not best then + util.fail("This item doesn't fit any of your slots") + end + local out, cmp = best.out, best.cmp + local current = b.itemsTab.items[b.itemsTab.slots[best.slot].selItemId] + + -- Each damage skill, measured as the main skill, with its totems counted. + local skills = util.array() + for gi, group in ipairs(b.skillsTab.socketGroupList) do + if group.enabled ~= false and group.displaySkillList and group.displaySkillList[1] then + local before = calcFunc({ mainSocketGroup = gi }) + local after = calcFunc({ mainSocketGroup = gi, repSlotName = best.slot, repItem = item }) + local sb, sa = M._summarize(before, ctx, gi), M._summarize(after, ctx, gi) + local eb, ea = M._effectiveDps(before, sb), M._effectiveDps(after, sa) + local lost = group.sourceItem and group.slot == best.slot + if lost then + ea = 0 + end + if eb > 0 or ea > 0 then + local ge = group.displaySkillList[#group.displaySkillList].activeEffect.grantedEffect + skills[#skills + 1] = { + group = gi, + name = ge and ge.name or ("Group " .. gi), + isMain = b.mainSocketGroup == gi or nil, + before = eb, + after = ea, + percent = eb > 0 and (ea / eb - 1) * 100 or nil, + totemsBefore = sb.totems, + totemsAfter = (not lost) and sa.totems or nil, + lost = lost or nil, + } + end + end + end + + local function stat(key) + return { before = baseOutput[key] or 0, after = out[key] or 0 } + end + local defence = { + Life = stat("Life"), EnergyShield = stat("EnergyShield"), Mana = stat("Mana"), TotalEHP = stat("TotalEHP"), + FireResist = stat("FireResist"), ColdResist = stat("ColdResist"), LightningResist = stat("LightningResist"), + ChaosResist = stat("ChaosResist"), PhysicalMaximumHitTaken = stat("PhysicalMaximumHitTaken"), + LifeRegenRecovery = stat("LifeRegenRecovery"), + } + + local lostSkills = util.array() + for _, group in ipairs(b.skillsTab.socketGroupList) do + if group.sourceItem and group.slot == best.slot and group.enabled ~= false and group.displaySkillList and group.displaySkillList[1] then + local ge = group.displaySkillList[1].activeEffect.grantedEffect + lostSkills[#lostSkills + 1] = ge and ge.name or "?" + end + end + if #lostSkills > 0 then + cmp.warnings[#cmp.warnings + 1] = "Removes skills granted by " .. (current and current.name or "the current item") .. ": " .. table.concat(lostSkills, ", ") + end + + -- Verdict, judged on the main skill (all totems) and effective HP. + local main + for _, s in ipairs(skills) do + if s.isMain then main = s end + end + local dpsPct = main and main.percent or 0 + local ehpPct = defence.TotalEHP.before > 0 and (defence.TotalEHP.after / defence.TotalEHP.before - 1) * 100 or 0 + local reasons = util.array() + local verdict + if #cmp.warnings > 0 then + for _, w in ipairs(cmp.warnings) do reasons[#reasons + 1] = w end + end + if main then + reasons[#reasons + 1] = ("%s: %s DPS%s"):format(main.name, ("%+.1f%%"):format(dpsPct), + main.totemsAfter and (" (" .. main.totemsAfter .. " totem" .. (main.totemsAfter == 1 and "" or "s") .. ")") or "") + end + reasons[#reasons + 1] = ("Effective HP %+.1f%%"):format(ehpPct) + local lostTotems = main and main.totemsBefore and main.totemsAfter and main.totemsAfter < main.totemsBefore + if dpsPct <= -2 or ehpPct <= -5 or lostTotems or (cmp.after.spiritShort and not cmp.before.spiritShort) then + verdict = "downgrade" + elseif (dpsPct >= 2 and ehpPct > -2) or (ehpPct >= 3 and dpsPct > -1) then + verdict = "upgrade" + else + verdict = "sidegrade" + end + return { + item = item.name, + slot = best.slot, + replaces = current and current.name or nil, + verdict = verdict, + reasons = reasons, + warnings = cmp.warnings, + budget = { before = cmp.before, after = cmp.after }, + lostSkills = lostSkills, + skills = skills, + defence = defence, + caveat = "PoB's calculation for your current Config settings. Skills the new item grants are not counted until it is equipped; skills the replaced item granted are removed.", + } +end + +return M diff --git a/engine/pobapi/api/build.lua b/engine/pobapi/api/build.lua new file mode 100644 index 0000000000..83c030b415 --- /dev/null +++ b/engine/pobapi/api/build.lua @@ -0,0 +1,108 @@ +-- build.*: create, load, export and describe the active build. +local util = require("pobapi.util") +local session = require("pobapi.session") + +local M = { } + +local function mainSkillName(b) + local group = b.skillsTab.socketGroupList[b.mainSocketGroup] + if not group or not group.displaySkillList then + return nil + end + local skill = group.displaySkillList[group.mainActiveSkill or 1] + return skill and skill.activeEffect and skill.activeEffect.grantedEffect and skill.activeEffect.grantedEffect.name +end + +function M.info() + local b = session.build() + local spec = b.spec + local used, ascUsed = spec:CountAllocNodes() + return { + name = b.buildName, + className = spec.curClassName, + ascendClassName = spec.curAscendClassName ~= "None" and spec.curAscendClassName or nil, + level = b.characterLevel, + levelAutoMode = b.characterLevelAutoMode, + treeVersion = spec.treeVersion, + targetVersion = b.targetVersion, + pointsUsed = used, + ascendancyPointsUsed = ascUsed, + mainSocketGroup = b.mainSocketGroup, + mainSkill = mainSkillName(b), + bandit = b.bandit, + } +end + +function M.new(params) + session.setMode(params.name or "New Build") + return M.info() +end + +function M.load_xml(params) + local xml = util.require(params, "xml", "string") + if not xml:find(" (limit or 50) do + table.remove(list) + end + return list +end + +function M.gems(params) + local query = params.query or "" + local res = util.array() + for id, gem in pairs(data.gems) do + local ge = gem.grantedEffect + local isSupport = ge and ge.support or false + if (query == "" or util.contains(gem.name, query) or util.contains(gem.tagString, query)) + and (params.support == nil or params.support == isSupport) then + res[#res + 1] = { + id = id, + name = gem.name, + support = isSupport, + tags = util.strip(gem.tagString), + description = ge and ge.description and util.strip(ge.description) or nil, + } + end + end + return limitList(res, params.limit) +end + +function M.uniques(params) + local query = params.query or "" + local res = util.array() + for name, item in pairs(main.uniqueDB.list) do + if (query == "" or util.contains(name, query) or util.contains(item.baseName, query)) + and (not params.type or util.contains(item.type, params.type)) then + res[#res + 1] = { name = name, baseName = item.baseName, type = item.type } + end + end + return limitList(res, params.limit) +end + +function M.unique(params) + local name = util.require(params, "name", "string") + local item = main.uniqueDB.list[name] + if not item then + for n, it in pairs(main.uniqueDB.list) do + if n:lower() == name:lower() then + item = it + end + end + end + if not item then + util.fail("Unique not found: " .. name, "not_found") + end + return { name = item.name, baseName = item.baseName, type = item.type, text = item:BuildRaw() } +end + +function M.bases(params) + local query = params.query or "" + local res = util.array() + for name, base in pairs(data.itemBases) do + if (query == "" or util.contains(name, query)) and (not params.type or util.contains(base.type, params.type)) then + res[#res + 1] = { name = name, type = base.type, subType = base.subType, level = base.req and base.req.level } + end + end + return limitList(res, params.limit) +end + +function M.classes() + local tree = session.build().spec.tree + local res = util.array() + for id, class in pairs(tree.classes) do + local entry = { id = id, name = class.name, ascendancies = util.array() } + for ascId, asc in pairs(class.classes or { }) do + if ascId ~= 0 then + entry.ascendancies[#entry.ascendancies + 1] = asc.name + end + end + table.sort(entry.ascendancies) + res[#res + 1] = entry + end + table.sort(res, function(a, b) return a.id < b.id end) + return res +end + +return M diff --git a/engine/pobapi/api/gear.lua b/engine/pobapi/api/gear.lua new file mode 100644 index 0000000000..b8c105b850 --- /dev/null +++ b/engine/pobapi/api/gear.lua @@ -0,0 +1,373 @@ +-- gear.*: gear and jewel advice. What each item and mod is worth, what to look for on a replacement (PoB's own +-- trade stat weighting), a weighted trade-site query per slot, and which uniques would help most. +local util = require("pobapi.util") +local session = require("pobapi.session") +local reference = require("pobapi.api.reference") + +local M = { } + +local tradeHelpers = LoadModule("Classes/TradeHelpers") + +-- Stat weights in TradeQueryGenerator's format. Results are reported as % of the current build (DPS% + EHP%). +local STAT_WEIGHTS = { + balanced = { { stat = "FullDPS", weightMult = 1 }, { stat = "TotalEHP", weightMult = 1 } }, + dps = { { stat = "FullDPS", weightMult = 1 } }, + ehp = { { stat = "TotalEHP", weightMult = 1 } }, +} +local SCORE = { + balanced = function(r) return r.dpsPercent + r.ehpPercent end, + dps = function(r) return r.dpsPercent end, + ehp = function(r) return r.ehpPercent end, +} +local DEAD = 0.05 -- percent + +local function metricOf(params) + local m = params.metric or "balanced" + return STAT_WEIGHTS[m] and m or "balanced" +end + +local function measurer(b, metric) + -- Item replacements also drop the replaced item's granted skills (see budget._calculator). + local calcFunc, baseOutput = require("pobapi.api.budget")._calculator(b) + local base = reference._headline(baseOutput) + local score = SCORE[metric] + return calcFunc, baseOutput, function(output) + local h = reference._headline(output) + local r = { + dpsPercent = reference._pct(base.dps, h.dps), + ehpPercent = reference._pct(base.ehp, h.ehp), + lifeDelta = h.life - base.life, + esDelta = h.es - base.es, + } + r.score = score(r) + reference._budgetDeltas(r, base, h) + return r + end +end + +-- Slots always advised on; others (weapon swap, Ring 3, limbs) only when something is equipped there. +local STANDARD = { + ["Weapon 1"] = "", ["Weapon 2"] = "", Helmet = "Helmet", ["Body Armour"] = "Body Armour", Gloves = "Gloves", + Boots = "Boots", Amulet = "Amulet", ["Ring 1"] = "Ring", ["Ring 2"] = "Ring", Belt = "Belt", +} +local TWO_HANDED = { Staff = true, Bow = true, Crossbow = true, ["Two Hand Sword"] = true, ["Two Hand Axe"] = true, + ["Two Hand Mace"] = true, ["Fishing Rod"] = true } + +-- Highest-level base of a type, used to evaluate an empty slot. +local function bestBase(baseType) + local best, bestLevel + for name, base in pairs(data.itemBases) do + if base.type == baseType and not base.hidden then + local level = base.req and base.req.level or 0 + if not best or level > bestLevel then + best, bestLevel = name, level + end + end + end + return best +end + +-- Base to evaluate an empty slot with, or nil when the slot can't be used. +local function emptySlotBase(b, name) + if name == "Weapon 2" then + local main = b.itemsTab.items[b.itemsTab.slots["Weapon 1"].selItemId] + local t = main and main.base and main.base.type + if t and TWO_HANDED[t] and t ~= "Bow" then + return nil + end + return bestBase(t == "Bow" and "Quiver" or (t == "Wand" or t == "Sceptre") and "Focus" or "Shield") + end + local baseType = STANDARD[name] + return baseType and baseType ~= "" and bestBase(baseType) or nil +end + +-- Equipment slots worth advising on, plus allocated jewel sockets. +local function adviceSlots(b) + local list = { } + for _, slot in ipairs(b.itemsTab.orderedSlots) do + local name = slot.slotName + local item = b.itemsTab.items[slot.selItemId] + if slot.nodeId then + if b.spec.allocNodes[slot.nodeId] then + list[#list + 1] = { slot = slot, name = name, item = item, jewel = true, base = item and item.baseName or "Sapphire" } + end + elseif item and not name:match("^Flask") and not name:find("Socket") then + list[#list + 1] = { slot = slot, name = name, item = item, base = item.baseName } + elseif not item and STANDARD[name] then + local base = emptySlotBase(b, name) + if base then + list[#list + 1] = { slot = slot, name = name, base = base } + end + end + end + return list +end + +function M.slots() + local b = session.build() + local res = util.array() + for _, s in ipairs(adviceSlots(b)) do + res[#res + 1] = { + slot = s.name, + jewel = s.jewel or nil, + nodeId = s.slot.nodeId, + item = s.item and { name = s.item.name, rarity = s.item.rarity, baseName = s.item.baseName } or nil, + } + end + return res +end + +local function copyItem(item) + local copy = new("Item"):Item(item:BuildRaw()) + copy:NormaliseQuality() + return copy +end + +local ATTR_NAMES = { Str = "Strength", Dex = "Dexterity", Int = "Intelligence" } + +-- Attribute that would fall below what gear and gems need (it was met in `base`). +local function brokenRequirement(base, out) + for short, long in pairs(ATTR_NAMES) do + if (base[short] or 0) >= (base["Req" .. short] or 0) and (out[short] or 0) < (out["Req" .. short] or 0) then + return long + end + end +end + +-- Value of each explicit/implicit mod line: the change from removing just that line. +local function modValues(slotName, item, calcFunc, measure, baseOutput) + local res = util.array() + for _, list in ipairs({ { key = "implicitModLines", kind = "implicit" }, { key = "explicitModLines", kind = "explicit" } }) do + for i, line in ipairs(item[list.key] or { }) do + if line.line and line.line ~= "" then + local copy = copyItem(item) + table.remove(copy[list.key], i) + copy:BuildAndParseRaw() + local out = calcFunc({ repSlotName = slotName, repItem = copy }) + local r = measure(out) + local needed = brokenRequirement(baseOutput, out) + -- Report what the line is worth (positive = it helps). + res[#res + 1] = { + text = util.strip(line.line), + kind = list.kind, + dpsPercent = -r.dpsPercent, + ehpPercent = -r.ehpPercent, + lifeDelta = -r.lifeDelta, + esDelta = -r.esDelta, + score = -r.score, + requirement = needed, + -- A unique's lines can't be rerolled, so an unmeasured one is reported as such rather than "dead". + dead = item.rarity ~= "UNIQUE" and not needed and math.abs(r.score) < DEAD and math.abs(r.lifeDelta) < 1 and math.abs(r.esDelta) < 1 or nil, + unmeasured = item.rarity == "UNIQUE" and not needed and math.abs(r.score) < DEAD or nil, + } + end + end + end + return res +end + +local generator +local tradeText +local function tradeGenerator(b) + if not generator then + generator = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = b.itemsTab }) + tradeText = { } + for _, entries in pairs(generator.modData) do + for _, entry in pairs(entries) do + if type(entry) == "table" and entry.tradeMod then + tradeText[entry.tradeMod.id] = entry.tradeMod.text + end + end + end + end + generator.itemsTab = b.itemsTab + return generator +end + +-- Run TradeQueryGenerator's mod weighting for one slot (synchronously; it yields every ~50ms). +local function tradeWeights(b, s, metric, calcFunc, baseOutput) + local testItem = new("Item"):Item("Rarity: RARE\nStat Tester\n" .. s.base) + local categoryStr, category = tradeHelpers.getTradeCategory(s.name, s.item or testItem) + if not category then + return nil + end + if category == "Jewel" then + category = "AnyJewel" + end + local gen = tradeGenerator(b) + local weights = STAT_WEIGHTS[metric] + local blankOutput = calcFunc({ repSlotName = s.name, repItem = testItem }) + local blankValue = gen.WeightedRatioOutputs(baseOutput, blankOutput, weights) * 1000 + gen.modWeights = { } + gen.alreadyWeightedMods = { } + gen.calcContext = { + itemCategoryQueryStr = categoryStr, + itemCategory = category, + special = { }, + testItem = testItem, + baseOutput = baseOutput, + baseStatValue = blankValue, + calcFunc = calcFunc, + options = { statWeights = weights, jewelType = "Any" }, + slot = s.slot, + } + local co = coroutine.create(gen.ExecuteQuery) + repeat + local ok, err = coroutine.resume(co, gen) + if not ok then + error(err, 0) + end + until coroutine.status(co) == "dead" + + -- What the current item's mods are worth on the same scale. + local current = 0 + if s.item then + testItem.explicitModLines = { } + for _, key in ipairs({ "explicitModLines", "implicitModLines" }) do + for _, line in ipairs(s.item[key] or { }) do + table.insert(testItem.explicitModLines, line) + end + end + testItem:BuildAndParseRaw() + local out = calcFunc({ repSlotName = s.name, repItem = testItem }) + current = gen.WeightedRatioOutputs(baseOutput, out, weights) * 1000 - blankValue + end + table.sort(gen.modWeights, function(a, c) return a.meanStatDiff > c.meanStatDiff end) + return { categoryStr = categoryStr, category = category, weights = gen.modWeights, current = current } +end + +local function lookForText(id, value) + local text = tradeText[id] or id + local v = math.floor(value + 0.5) + text = text:gsub("%+?#", (text:find("%+#") and v >= 0) and ("+" .. v) or tostring(v), 1):gsub("%s*%(Local%)", "") + return (text:gsub("^%s+", ""):gsub("%s+$", "")) +end + +-- Full advice for one slot. +function M.slot(params) + local b = session.build() + local name = util.require(params, "slot", "string") + local metric = metricOf(params) + local target + for _, s in ipairs(adviceSlots(b)) do + if s.name == name then + target = s + end + end + if not target then + util.fail("Unknown or unadvisable slot: " .. name) + end + local calcFunc, baseOutput, measure = measurer(b, metric) + local res = { slot = name, metric = metric, jewel = target.jewel or nil, testBase = not target.item and target.base or nil } + if target.item then + local it = target.item + res.item = { name = it.name, rarity = it.rarity, baseName = it.baseName, title = it.title } + -- Worth of the whole item: swap it for the bare base, and for nothing at all. + local blank = new("Item"):Item("Rarity: RARE\nStat Tester\n" .. it.baseName) + res.modsWorth = (function() + local r = measure(calcFunc({ repSlotName = name, repItem = blank })) + return { dpsPercent = -r.dpsPercent, ehpPercent = -r.ehpPercent, lifeDelta = -r.lifeDelta, esDelta = -r.esDelta, score = -r.score } + end)() + res.mods = modValues(name, it, calcFunc, measure, baseOutput) + end + + local tw = tradeWeights(b, target, metric, calcFunc, baseOutput) + if tw then + local look = util.array() + local limit = tonumber(params.limit) or 12 + for i, w in ipairs(tw.weights) do + if i > limit then break end + local value = w.weight ~= 0 and w.meanStatDiff / w.weight or 0 + look[#look + 1] = { id = w.tradeModId, text = lookForText(w.tradeModId, value), value = value, percent = w.meanStatDiff / 10 } + end + res.lookFor = look + res.currentPercent = tw.current / 10 + -- A good replacement: the four most valuable mods at average rolls. + local ideal = 0 + for i = 1, math.min(4, #tw.weights) do + ideal = ideal + tw.weights[i].meanStatDiff / 10 + end + res.idealPercent = ideal + res.headroom = math.max(0, ideal - res.currentPercent) + -- Weighted-sum query for the trade site (the service adds the league to build the URL). + local filters = { } + for i, w in ipairs(tw.weights) do + if i > 35 then break end + filters[#filters + 1] = { id = w.tradeModId, value = { weight = w.weight } } + end + res.tradeQuery = { + query = { + filters = { + type_filters = { filters = { category = { option = tw.categoryStr }, rarity = { option = "nonunique" } } }, + misc_filters = { disabled = false, filters = { mirrored = false } }, + }, + status = { option = "online" }, + stats = { { type = "weight", value = { min = math.max(0, tw.current * 0.5) }, filters = filters } }, + }, + sort = { ["statgroup.0"] = "desc" }, + engine = "new", + } + else + res.unsupported = true + end + if res.mods then + local dead = 0 + for _, m in ipairs(res.mods) do + if m.dead then dead = dead + 1 end + end + res.deadMods = dead + end + return res +end + +-- Uniques from PoB's database that fit a slot, measured in the current build, best first per slot. +function M.uniques(params) + local b = session.build() + local metric = metricOf(params) + local limit = tonumber(params.limit) or 8 + local calcFunc, _, measure = measurer(b, metric) + local slots = adviceSlots(b) + local bySlot, best = { }, { } + for name, item in pairs(main.uniqueDB.list) do + if item.base and not item.base.flask then + local top + for _, s in ipairs(slots) do + if b.itemsTab:IsItemValidForSlot(item, s.name) and not (s.item and s.item.title == item.title) then + local r = measure(calcFunc({ repSlotName = s.name, repItem = item })) + if not top or r.score > top.r.score then + top = { r = r, slot = s.name } + end + end + end + if top then + local key = top.slot:match("^Jewel") and "Jewel" or top.slot:gsub(" %d$", "") + bySlot[key] = bySlot[key] or { } + local row = top.r + row.name = item.title or name + row.baseName = item.baseName + row.slot = top.slot + row.levelRequirement = item.requirements and item.requirements.level or nil + table.insert(bySlot[key], row) + end + end + end + local res = util.array() + for key, rows in pairs(bySlot) do + table.sort(rows, function(a, c) return a.score > c.score end) + local kept = util.array() + for i, row in ipairs(rows) do + if i > limit or row.score < DEAD then break end + kept[#kept + 1] = row + end + if #kept > 0 then + res[#res + 1] = { slot = key, items = kept } + end + end + table.sort(res, function(a, c) return a.items[1].score > c.items[1].score end) + return res +end + +M._metricOf = metricOf +M._measurer = measurer + +return M diff --git a/engine/pobapi/api/gems.lua b/engine/pobapi/api/gems.lua new file mode 100644 index 0000000000..fed0b07417 --- /dev/null +++ b/engine/pobapi/api/gems.lua @@ -0,0 +1,204 @@ +-- gems.*: skill and support gem advice. What each support is worth, the best replacement for the weakest support +-- in each skill, gem level/quality upgrades and setup problems. Uses PoB's misc calculator, which rebuilds skills +-- from the live socket groups on every call (the same trick PoB's gem picker uses to sort gems by DPS). +local util = require("pobapi.util") +local session = require("pobapi.session") +local reference = require("pobapi.api.reference") + +local M = { } + +local SCORE = { + balanced = function(r) return r.dpsPercent + r.ehpPercent end, + dps = function(r) return r.dpsPercent end, + ehp = function(r) return r.ehpPercent end, +} +local MIN = 0.05 -- percent + +local function family(name) + return (name:gsub("%s+[IVX]+$", "")) +end + +local function isSupport(gem) + return gem.gemData and gem.gemData.grantedEffect and gem.gemData.grantedEffect.support +end + +function M.advise(params) + local b = session.build() + local metric = SCORE[params.metric or ""] and params.metric or "balanced" + local score = SCORE[metric] + local calcFunc, baseOutput = b.calcsTab:GetMiscCalculator() + local base = reference._headline(baseOutput) + -- Measure against `ref` (the build, or a secondary skill's own numbers when it is evaluated as main). + local function measurer(ref, ownSkill, group) + return function(output) + local h = reference._headline(output, nil, group) + local r = { + dpsPercent = reference._pct(ref.dps, h.dps), + ehpPercent = ownSkill and 0 or reference._pct(ref.ehp, h.ehp), + lifeDelta = ownSkill and 0 or h.life - ref.life, + esDelta = ownSkill and 0 or h.es - ref.es, + } + r.score = ownSkill and r.dpsPercent or score(r) + reference._budgetDeltas(r, ref, h) + return r + end + end + + -- Candidate supports (non-legacy, not item/tree granted), measured per group below. + local supports = { } + for id, gemData in pairs(data.gems) do + local ge = gemData.grantedEffect + if ge and ge.support and not ge.legacy and not ge.fromItem and not ge.fromTree then + supports[#supports + 1] = { id = id, data = gemData } + end + end + + local groups, upgrades, problems = util.array(), util.array(), util.array() + for gi, group in ipairs(b.skillsTab.socketGroupList) do + if group.enabled ~= false and group.gemList and #group.gemList > 0 and not group.source then + local actives = group.displaySkillList or { } + local skillNames = util.array() + for _, skill in ipairs(actives) do + local ge = skill.activeEffect and skill.activeEffect.grantedEffect + skillNames[#skillNames + 1] = ge and ge.name or "?" + end + -- Secondary damage skills are judged on their own DPS (evaluated as the main skill); utility skills + -- (curses, auras, buffs) on what they do for the build. + local isMain = b.mainSocketGroup == gi + local override = { } + local ref, ownSkill = base, false + if not isMain then + local own = reference._headline(calcFunc({ mainSocketGroup = gi }), nil, gi) + if own.dps > 0 then + override, ref, ownSkill = { mainSocketGroup = gi }, own, true + end + end + local measure = measurer(ref, ownSkill, ownSkill and gi or nil) + local entry = { + measuredOn = ownSkill and "skill" or "build", + index = gi, + label = (group.label ~= "" and group.label) or util.strip(group.displayLabel or ""), + skills = skillNames, + isMain = isMain, + gems = util.array(), + } + local families, weakest = { }, nil + for idx, gem in ipairs(group.gemList) do + local name = gem.gemData and gem.gemData.name or gem.nameSpec + local g = { index = idx, name = name, support = isSupport(gem) or false, level = gem.level, quality = gem.quality, enabled = gem.enabled ~= false, problems = util.array() } + if not gem.gemData then + g.problems[#g.problems + 1] = "Not recognised by PoB" + elseif gem.enabled == false then + g.problems[#g.problems + 1] = "Disabled" + else + if g.support then + local fam = family(name) + if families[fam] then + g.problems[#g.problems + 1] = "Duplicate of " .. families[fam] + end + families[fam] = name + if gem.supportEffect and gem.supportEffect.isSupporting and next(gem.supportEffect.isSupporting) == nil then + g.problems[#g.problems + 1] = "Doesn't support any skill in this group" + end + -- Worth: switch it off. + gem.enabled = false + local r = measure(calcFunc(override)) + gem.enabled = true + g.worth = { dpsPercent = -r.dpsPercent, ehpPercent = -r.ehpPercent, lifeDelta = -r.lifeDelta, esDelta = -r.esDelta, score = -r.score } + if r.score >= MIN then + g.problems[#g.problems + 1] = ("Lowers output: removing it gives %+.1f%%"):format(r.score) + elseif math.abs(r.score) < MIN then + g.noEffect = true -- utility (cost, area, duration) or not modelled + end + if not weakest or g.worth.score < weakest.g.worth.score then + weakest = { g = g, gem = gem } + end + end + -- Level and quality upgrades. + local maxLevel = gem.gemData.naturalMaxLevel or 20 + if (gem.level or 1) < maxLevel then + local old = gem.level + gem.level = old + 1 + local r = measure(calcFunc(override)) + gem.level = old + if r.score >= MIN then + g.levelUp = r + upgrades[#upgrades + 1] = { kind = "level", group = gi, index = idx, name = name, from = old, to = old + 1, gain = r, + text = ("Level %s %d → %d"):format(name, old, old + 1) } + end + end + if not g.support and (gem.quality or 0) < 20 then + local old = gem.quality or 0 + gem.quality = 20 + local r = measure(calcFunc(override)) + gem.quality = old + if r.score >= MIN then + g.qualityUp = r + upgrades[#upgrades + 1] = { kind = "quality", group = gi, index = idx, name = name, from = old, to = 20, gain = r, + text = ("Quality %s %d%% → 20%%"):format(name, old) } + end + end + end + for _, p in ipairs(g.problems) do + problems[#problems + 1] = { group = gi, index = idx, name = name, problem = p } + end + entry.gems[#entry.gems + 1] = g + end + + -- Best replacement for the weakest support. + if weakest and actives[1] then + local gem = weakest.gem + local saved = { gemData = gem.gemData, level = gem.level, displayEffect = gem.displayEffect } + local alternatives = { } + -- Fresh calculator: the accelerated path caches its environment between calls. + local swapCalc = b.calcsTab:GetMiscCalculator() + for _, cand in ipairs(supports) do + local fam = family(cand.data.name) + if not families[fam] or fam == family(saved.gemData.name) then + local ok = false + for _, skill in ipairs(actives) do + if calcLib.canGrantedEffectSupportActiveSkill(cand.data.grantedEffect, skill) then + ok = true + break + end + end + if ok and cand.data ~= saved.gemData then + gem.gemData = cand.data + gem.level = b.skillsTab:ProcessGemLevel(cand.data) + gem.displayEffect = nil + local r = measure(swapCalc(override)) + r.name = cand.data.name + r.lineage = cand.data.grantedEffect.isLineage or nil + alternatives[#alternatives + 1] = r + end + end + end + gem.gemData, gem.level, gem.displayEffect = saved.gemData, saved.level, saved.displayEffect + table.sort(alternatives, function(a, c) return a.score > c.score end) + -- Regular supports first; the best lineage (rare, unique-like) support is listed separately. + local top, bestAlt, bestLineage = util.array(), nil, nil + for _, alt in ipairs(alternatives) do + if alt.lineage then + bestLineage = bestLineage or alt + else + bestAlt = bestAlt or alt + if #top < 3 then top[#top + 1] = alt end + end + end + if bestLineage and bestLineage.score >= MIN and (not bestAlt or bestLineage.score > bestAlt.score) then + entry.lineage = bestLineage + end + if bestAlt and bestAlt.score >= MIN then + entry.swap = { replace = weakest.g.name, index = weakest.g.index, with = bestAlt.name, gain = bestAlt, alternatives = top } + upgrades[#upgrades + 1] = { kind = "swap", group = gi, index = weakest.g.index, name = weakest.g.name, with = bestAlt.name, gain = bestAlt, + text = ("Replace %s with %s in %s"):format(weakest.g.name, bestAlt.name, skillNames[1] or entry.label) } + end + end + groups[#groups + 1] = entry + end + end + table.sort(upgrades, function(a, c) return a.gain.score > c.gain.score end) + return { metric = metric, base = base, groups = groups, upgrades = upgrades, problems = problems } +end + +return M diff --git a/engine/pobapi/api/items.lua b/engine/pobapi/api/items.lua new file mode 100644 index 0000000000..56d4f4ee3a --- /dev/null +++ b/engine/pobapi/api/items.lua @@ -0,0 +1,130 @@ +-- items.*: equipped items, equipping from item text and item parsing. +local util = require("pobapi.util") +local session = require("pobapi.session") + +local M = { } + +local function modLines(item) + local lines = util.array() + local function add(list, kind) + for _, line in ipairs(list or { }) do + if line.line and not line.variantList or (line.variantList and line.variantList[item.variant]) then + lines[#lines + 1] = { + text = util.strip(line.line), + kind = kind, + supported = not line.extra, + crafted = line.crafted or nil, + } + end + end + end + add(item.enchantModLines, "enchant") + add(item.runeModLines, "rune") + add(item.implicitModLines, "implicit") + add(item.explicitModLines, "explicit") + return lines +end + +function M.describe(item, full) + if not item then + return nil + end + local res = { + id = item.id, + name = item.name, + title = item.title, + baseName = item.baseName, + type = item.type, + rarity = item.rarity, + corrupted = item.corrupted or nil, + itemLevel = item.itemLevel, + quality = item.quality, + } + if full then + res.mods = modLines(item) + res.text = item:BuildRaw() + end + return res +end + +function M.list(params) + local b = session.build() + local full = params.full ~= false + local res = util.array() + for _, slot in ipairs(b.itemsTab.orderedSlots) do + local item = b.itemsTab.items[slot.selItemId] + if item or params.includeEmpty then + res[#res + 1] = { + slot = slot.slotName, + label = slot.label, + jewelNodeId = slot.nodeId, + item = M.describe(item, full), + } + end + end + return res +end + +function M.slots() + local b = session.build() + local res = util.array() + for _, slot in ipairs(b.itemsTab.orderedSlots) do + if not slot.nodeId then + res[#res + 1] = slot.slotName + end + end + return res +end + +local function parse(text) + local item = new("Item"):Item(text) + if not item.base then + util.fail("Could not parse item (unknown base type). Paste the full item text copied from the game (Ctrl+C).") + end + item:NormaliseQuality() + item:BuildModList() + return item +end + +function M.parse(params) + local item = parse(util.require(params, "text", "string")) + local res = M.describe(item, true) + res.primarySlot = item:GetPrimarySlot() + return res +end + +function M.equip(params) + local text = util.require(params, "text", "string") + local b = session.build() + local itemsTab = b.itemsTab + parse(text) -- validate first for a clean error + itemsTab:CreateDisplayItemFromRaw(text, true) + local item = itemsTab.displayItem + local slotName = params.slot or item:GetPrimarySlot() + local slot = itemsTab.slots[slotName] + if not slot then + util.fail("Unknown slot: " .. tostring(slotName)) + end + itemsTab:AddDisplayItem(true) + slot:SetSelItemId(item.id) + itemsTab:PopulateSlots() + itemsTab:AddUndoState() + session.recalc() + return { slot = slotName, item = M.describe(item, true) } +end + +function M.unequip(params) + local slotName = util.require(params, "slot", "string") + local b = session.build() + local slot = b.itemsTab.slots[slotName] + if not slot then + util.fail("Unknown slot: " .. slotName) + end + slot:SetSelItemId(0) + b.itemsTab:PopulateSlots() + b.itemsTab:AddUndoState() + session.recalc() + return true +end + +return M diff --git a/engine/pobapi/api/reference.lua b/engine/pobapi/api/reference.lua new file mode 100644 index 0000000000..04b6680950 --- /dev/null +++ b/engine/pobapi/api/reference.lua @@ -0,0 +1,509 @@ +-- reference.*: compare the live build with a PoE2 BuildPlanner (.build) guide and rank steps toward it. +-- The .build JSON (GGG's in-game planner format, also written by PoB's BuildExportPoE2) is passed in as +-- params.reference on every call; the Python service keeps it between calls. +local util = require("pobapi.util") +local session = require("pobapi.session") +local stats = require("pobapi.api.stats") +local budget = require("pobapi.api.budget") + +local M = { } + +-- Entries apply to a character level range; with no level given every entry applies. +local function inStage(entry, level) + local interval = type(entry) == "table" and entry.level_interval + if not level or type(interval) ~= "table" then + return true + end + return (tonumber(interval[1]) or 1) <= level and level <= (tonumber(interval[2]) or 100) +end + +-- Strip PoE2 custom text markup ({...}, {...}) down to plain text. +local function plainText(text) + if type(text) ~= "string" or text == "" then + return nil + end + text = text:gsub("\r\n", "\n"):gsub("<[^<>]*>", ""):gsub("[{}]", "") + text = text:gsub("[ \t]+\n", "\n"):gsub("\n\n\n+", "\n\n"):gsub("^%s+", ""):gsub("%s+$", "") + return text ~= "" and text or nil +end + +local function stageList(ref) + local seen, list = { }, util.array() + for _, key in ipairs({ "passives", "skills", "inventory_slots" }) do + for _, entry in ipairs(type(ref[key]) == "table" and ref[key] or { }) do + local interval = entry.level_interval + if type(interval) == "table" and interval[1] then + local id = interval[1] .. "-" .. (interval[2] or 100) + if not seen[id] then + seen[id] = true + list[#list + 1] = { from = tonumber(interval[1]), to = tonumber(interval[2]) or 100 } + end + end + end + end + table.sort(list, function(a, b) return a.from < b.from or (a.from == b.from and a.to < b.to) end) + return list +end + +local function getReference(params) + local ref = util.require(params, "reference", "table") + if type(ref.passives) ~= "table" and type(ref.skills) ~= "table" and type(ref.inventory_slots) ~= "table" then + util.fail("Not a PoE2 BuildPlanner file (no passives, skills or inventory_slots)") + end + return ref, tonumber(params.level) +end + +local function nodeInfo(node) + return { id = node.id, name = node.dn, type = node.type, ascendancy = node.ascendancyName } +end + +local function isStart(node) + return node.type == "ClassStart" or node.type == "AscendClassStart" +end + +-- Resolve the reference passives against the live tree. +local function resolvePassives(spec, ref, level) + local byString = { } + for _, node in pairs(spec.nodes) do + if node.stringId then + byString[node.stringId] = node + end + end + local wanted, weaponSets, unknown = { }, { }, util.array() + for _, entry in ipairs(type(ref.passives) == "table" and ref.passives or { }) do + if inStage(entry, level) then + local id = type(entry) == "string" and entry or entry.id + local node = id and byString[id] + if node and not isStart(node) then + wanted[node] = true + weaponSets[node] = tonumber(type(entry) == "table" and entry.weapon_set) or 0 + elseif id and not node then + unknown[#unknown + 1] = id + end + end + end + return wanted, weaponSets, unknown +end + +local function resolveAscendancy(spec, ref) + local internal = ref.ascendancy + local entry = internal and spec.tree.internalAscendNameMap and spec.tree.internalAscendNameMap[internal] + if not entry then + return nil + end + return { + internalId = internal, + className = entry.class.name, + classId = entry.classId, + name = entry.ascendClass.id or entry.ascendClass.name, + ascendClassId = entry.ascendClassId, + } +end + +local gemByTail +local function gemFor(gameId) + if type(gameId) ~= "string" then + return nil + end + local variants = data.gemsByGameId[gameId] + if not variants then + -- Tolerate path variations (e.g. "Metadata/Items/Gem/..." vs "Metadata/Items/Gems/..."). + if not gemByTail then + gemByTail = { } + for id, v in pairs(data.gemsByGameId) do + gemByTail[id:match("([^/]+)$")] = v + end + end + variants = gemByTail[gameId:match("([^/]+)$")] + end + local best + for variantId, gem in pairs(variants or { }) do + if not best or variantId < best.variantId then + best = gem + end + end + return best +end + +local function supportFamily(name) + return (name:gsub("%s+[IVX]+$", "")) +end + +local function compareSkills(b, ref, level) + local groups = { } + for index, group in ipairs(b.skillsTab.socketGroupList) do + local names, supports = { }, { } + for _, gem in ipairs(group.gemList or { }) do + local gemData = gem.gemData + if gemData then + names[gemData.name] = true + if gemData.grantedEffect and gemData.grantedEffect.support then + supports[gemData.name] = true + end + end + end + groups[#groups + 1] = { index = index, group = group, names = names, supports = supports } + end + local res, unknown = util.array(), util.array() + for _, entry in ipairs(type(ref.skills) == "table" and ref.skills or { }) do + if inStage(entry, level) then + local gem = gemFor(entry.id) + if not gem then + unknown[#unknown + 1] = tostring(entry.id) + else + local refSupports = util.array() + for _, sup in ipairs(type(entry.support_skills) == "table" and entry.support_skills or { }) do + local supId = type(sup) == "string" and sup or (type(sup) == "table" and sup.id) + if inStage(sup, level) then + local supGem = gemFor(supId) + if supGem then + refSupports[#refSupports + 1] = supGem.name + else + unknown[#unknown + 1] = tostring(supId) + end + end + end + local match + for _, g in ipairs(groups) do + if g.names[gem.name] and not g.supports[gem.name] then + match = g + break + end + end + local row = { + name = gem.name, + note = plainText(entry.additional_text), + found = match ~= nil, + supports = refSupports, + missingSupports = util.array(), + extraSupports = util.array(), + tierDifferences = util.array(), + } + if match then + row.group = match.index + row.enabled = match.group.enabled ~= false + -- Supports come in tiers ("Biting Frost I/II"); a different tier of the same support is a match. + local haveByFamily, used = { }, { } + for name in pairs(match.supports) do + haveByFamily[supportFamily(name)] = name + end + for _, name in ipairs(refSupports) do + local have = match.supports[name] and name or haveByFamily[supportFamily(name)] + if not have then + row.missingSupports[#row.missingSupports + 1] = name + else + used[have] = true + if have ~= name then + row.tierDifferences[#row.tierDifferences + 1] = { reference = name, current = have } + end + end + end + for name in pairs(match.supports) do + if not used[name] then + row.extraSupports[#row.extraSupports + 1] = name + end + end + table.sort(row.extraSupports) + end + res[#res + 1] = row + end + end + end + return res, unknown +end + +-- .build inventory ids -> PoB slot names (inverse of Data/InventorySlots, plus charms stored as Trinket1 x2-4). +local function slotFor(inventoryId, x) + x = tonumber(x) or 0 + for slotName, mapping in pairs(data.buildFileInventorySlotMap) do + if mapping.id == inventoryId and (mapping.slot_x or 0) == x then + return slotName + end + end + if inventoryId == "Trinket1" and x >= 2 and x <= 4 then + return "Charm " .. (x - 1) + end + if inventoryId == "Flask1" and x <= 1 then + return "Flask " .. (x + 1) + end +end + +local function compareItems(b, ref, level) + local res = util.array() + for _, entry in ipairs(type(ref.inventory_slots) == "table" and ref.inventory_slots or { }) do + if inStage(entry, level) then + local slot = slotFor(entry.inventory_id, entry.slot_x) + local slotData = slot and b.itemsTab.slots[slot] + local item = slotData and b.itemsTab.items[slotData.selItemId] + local row = { + slot = slot or tostring(entry.inventory_id) .. " " .. tostring(entry.slot_x or 0), + uniqueName = entry.unique_name, + note = plainText(entry.additional_text), + } + if item then + row.equipped = { name = item.name, title = item.title, baseName = item.baseName, rarity = item.rarity } + if entry.unique_name then + row.match = item.title == entry.unique_name + end + end + res[#res + 1] = row + end + end + table.sort(res, function(a, c) return a.slot < c.slot end) + return res +end + +function M.compare(params) + local ref, level = getReference(params) + local b = session.build() + local spec = b.spec + level = level or b.characterLevel + local wanted, weaponSets, unknownPassives = resolvePassives(spec, ref, level) + local missing, extra, matched = util.array(), util.array(), 0 + local weaponSetMismatch = 0 + local refPoints, refAscPoints = 0, 0 + for node in pairs(wanted) do + if node.ascendancyName then refAscPoints = refAscPoints + 1 else refPoints = refPoints + 1 end + if node.alloc then + matched = matched + 1 + if (node.allocMode or 0) ~= weaponSets[node] then + weaponSetMismatch = weaponSetMismatch + 1 + end + else + missing[#missing + 1] = nodeInfo(node) + end + end + for _, node in pairs(spec.allocNodes) do + if not wanted[node] and not isStart(node) and node.id < 65536 then + extra[#extra + 1] = nodeInfo(node) + end + end + local byName = function(a, c) return (a.name or "") < (c.name or "") end + table.sort(missing, byName) + table.sort(extra, byName) + local skillRows, unknownGems = compareSkills(b, ref, level) + local used, ascUsed = spec:CountAllocNodes() + return { + name = ref.name, + author = ref.author, + link = ref.link, + description = plainText(ref.description), + level = level, + stages = stageList(ref), + ascendancy = resolveAscendancy(spec, ref), + current = { className = spec.curClassName, ascendClassName = spec.curAscendClassName, points = used, ascendancyPoints = ascUsed }, + passives = { + referencePoints = refPoints, + referenceAscendancyPoints = refAscPoints, + matched = matched, + missing = missing, + extra = extra, + weaponSetMismatch = weaponSetMismatch, + unknown = unknownPassives, + }, + skills = skillRows, + items = compareItems(b, ref, level), + unknownGems = unknownGems, + } +end + +-- Headline numbers from a calc output, for ranking. +-- Headline numbers. DPS is the main skill across every totem the Spirit budget can keep up (see budget.lua): +-- PoB reports totem DPS per totem, so a change that costs totems must show up as lost damage. +local function headline(output, ctx, group) + local summary = budget._summarize(output, ctx or budget._context(), group) + return { + dps = budget._effectiveDps(output, summary), + ehp = output.TotalEHP or 0, + life = output.Life or 0, + es = output.EnergyShield or 0, + totems = summary.totems, + spirit = summary.spirit, + } +end + +-- Totem/Spirit side effects of a change, added to a gain row only when something moved. +local function budgetDeltas(r, base, h) + if h.totems and base.totems and h.totems ~= base.totems then + r.totemsDelta = h.totems - base.totems + end + if (h.spirit or 0) ~= (base.spirit or 0) then + r.spiritDelta = (h.spirit or 0) - (base.spirit or 0) + end + return r +end + +local function pct(before, after) + if before == 0 then + return after ~= 0 and 100 or 0 + end + return (after - before) / math.abs(before) * 100 +end + +-- Shortest route from the current allocation to `target` that only uses reference nodes. +local function routeTo(target, missingSet) + local prev, queue, head = { [target] = false }, { target }, 1 + while head <= #queue do + local node = queue[head] + head = head + 1 + for _, other in ipairs(node.linked or { }) do + if other.alloc and (not other.ascendancyName or other.ascendancyName == node.ascendancyName) then + local path, cur = { }, node + while cur do + path[#path + 1] = cur + cur = prev[cur] + end + return path + end + if prev[other] == nil and missingSet[other] then + prev[other] = node + queue[#queue + 1] = other + end + end + end +end + +-- Stat impact: the whole reference tree, plus ranked next steps and what each extra node is worth. +function M.impact(params) + local ref, level = getReference(params) + local b = session.build() + local spec = b.spec + level = level or b.characterLevel + local limit = tonumber(params.limit) or 40 + local wanted = resolvePassives(spec, ref, level) + local calcFunc, baseOutput = b.calcsTab:GetMiscCalculator() + local base = headline(baseOutput) + + local missingSet, extraSet = { }, { } + for node in pairs(wanted) do + if not node.alloc then + missingSet[node] = true + end + end + for _, node in pairs(spec.allocNodes) do + if not wanted[node] and not isStart(node) and node.id < 65536 then + extraSet[node] = true + end + end + local function count(set) + local n = 0 + for _ in pairs(set) do n = n + 1 end + return n + end + local function summarise(output, points) + local h = headline(output) + return budgetDeltas({ + points = points, + dpsPercent = pct(base.dps, h.dps), + ehpPercent = pct(base.ehp, h.ehp), + lifeDelta = h.life - base.life, + esDelta = h.es - base.es, + }, base, h) + end + + local fullOutput = calcFunc({ addNodes = missingSet, removeNodes = extraSet }) + local full = summarise(fullOutput, count(missingSet) - count(extraSet)) + full.added = count(missingSet) + full.removed = count(extraSet) + full.changes = stats.diff(baseOutput, fullOutput) + + -- Next steps: each notable/keystone/socket the guide takes, via its route through other guide nodes. + local steps = util.array() + for node in pairs(missingSet) do + if node.type == "Notable" or node.type == "Keystone" or node.type == "Socket" then + local path = routeTo(node, missingSet) + if path then + -- path[1] touches the current tree; path[#path] is the target. + local set, ids = { }, util.array() + for i = 1, #path do + set[path[i]] = true + ids[#ids + 1] = path[i].id + end + local row = summarise(calcFunc({ addNodes = set }), #path) + row.id, row.name, row.type, row.ascendancy, row.path = node.id, node.dn, node.type, node.ascendancyName, ids + row.score = (row.dpsPercent + row.ehpPercent) / #path + steps[#steps + 1] = row + end + end + end + table.sort(steps, function(a, c) return a.score > c.score end) + while #steps > limit do + table.remove(steps) + end + + -- Extra notables/keystones: what removing each one alone would cost. + local extras = util.array() + for node in pairs(extraSet) do + if node.type == "Notable" or node.type == "Keystone" then + local row = summarise(calcFunc({ removeNodes = { [node] = true } }), -1) + row.id, row.name, row.type, row.ascendancy = node.id, node.dn, node.type, node.ascendancyName + extras[#extras + 1] = row + end + end + table.sort(extras, function(a, c) return a.dpsPercent + a.ehpPercent > c.dpsPercent + c.ehpPercent end) + + return { level = level, base = base, fullTree = full, steps = steps, extras = extras } +end + +-- Replace the passive tree with the reference tree (and its ascendancy when it belongs to the current class). +-- Attribute choices on nodes kept are preserved; new attribute nodes take the most common existing choice. +function M.apply_tree(params) + local ref, level = getReference(params) + local b = session.build() + local spec = b.spec + level = level or b.characterLevel + local wanted, weaponSets = resolvePassives(spec, ref, level) + local asc = resolveAscendancy(spec, ref) + local ascendClassId = spec.curAscendClassId + if asc and asc.classId == spec.curClassId then + ascendClassId = asc.ascendClassId + end + local choices, tally = { }, { } + for id, node in pairs(spec.hashOverrides or { }) do + if node.dn then + choices[id] = node.dn + tally[node.dn] = (tally[node.dn] or 0) + 1 + end + end + local favourite + for name, n in pairs(tally) do + if not favourite or n > tally[favourite] then + favourite = name + end + end + local hashes, sets = { }, { } + for node in pairs(wanted) do + hashes[#hashes + 1] = node.id + if weaponSets[node] > 0 then + sets[node.id] = weaponSets[node] + end + end + spec:ImportFromNodeList(nil, spec.curClassId, ascendClassId, 0, hashes, sets, { }, { }) + for _, id in ipairs(hashes) do + local treeNode = spec.tree.nodes[id] + local choice = choices[id] or favourite + if treeNode and treeNode.isAttribute and choice then + for index, option in ipairs(treeNode.options or { }) do + if (option.dn or option.name) == choice then + spec:SwitchAttributeNode(id, index) + spec:ReplaceNode(spec.nodes[id], spec.hashOverrides[id]) + break + end + end + end + end + spec:BuildAllDependsAndPaths() + session.recalc() + return require("pobapi.api.tree").get() +end + +-- Shared with advisor.* (leading underscore keeps them off the RPC registry). +M._getReference = getReference +M._resolvePassives = resolvePassives +M._headline = headline +M._budgetDeltas = budgetDeltas +M._pct = pct +M._routeTo = routeTo +M._isStart = isStart + +return M diff --git a/engine/pobapi/api/skills.lua b/engine/pobapi/api/skills.lua new file mode 100644 index 0000000000..f88fe9f08c --- /dev/null +++ b/engine/pobapi/api/skills.lua @@ -0,0 +1,192 @@ +-- skills.*: socket groups, gems and main skill selection. +local util = require("pobapi.util") +local session = require("pobapi.session") + +local M = { } + +local function gemInfo(gem) + local gemData = gem.gemData + local granted = gemData and gemData.grantedEffect + return { + name = gemData and gemData.name or gem.nameSpec, + level = gem.level, + quality = gem.quality, + enabled = gem.enabled ~= false, + support = granted and granted.support or false, + recognised = gemData ~= nil, + } +end + +local function groupInfo(b, index, group) + local res = { + index = index, + label = group.label ~= "" and group.label or nil, + displayLabel = util.strip(group.displayLabel), + slot = group.slot, + enabled = group.enabled ~= false, + includeInFullDPS = group.includeInFullDPS or false, + isMain = b.mainSocketGroup == index, + gems = util.array(), + activeSkills = util.array(), + } + for _, gem in ipairs(group.gemList or { }) do + res.gems[#res.gems + 1] = gemInfo(gem) + end + for i, skill in ipairs(group.displaySkillList or { }) do + local ge = skill.activeEffect and skill.activeEffect.grantedEffect + res.activeSkills[#res.activeSkills + 1] = { + index = i, + name = ge and ge.name or "?", + isMain = res.isMain and (group.mainActiveSkill or 1) == i, + } + end + return res +end + +function M.list() + local b = session.build() + local res = util.array() + for i, group in ipairs(b.skillsTab.socketGroupList) do + res[#res + 1] = groupInfo(b, i, group) + end + return res +end + +local function getGroup(params) + local b = session.build() + local index = util.require(params, "group", "number") + local group = b.skillsTab.socketGroupList[index] + if not group then + util.fail("No socket group at index " .. index) + end + return b, group, index +end + +function M.set_main(params) + local b, group, index = getGroup(params) + b.mainSocketGroup = index + if params.skill then + group.mainActiveSkill = params.skill + end + session.recalc() + return groupInfo(b, index, group) +end + +-- Adds a socket group using PoB's clipboard format, one gem per line: +-- " / " e.g. "Spark 20/0 1" +-- Plain gem names are also accepted and default to level 20, quality 0. +function M.add(params) + local b = session.build() + local lines = { } + if params.label then + lines[#lines + 1] = "Label: " .. params.label + end + if params.slot then + lines[#lines + 1] = "Slot: " .. params.slot + end + local gems = params.gems or { } + if type(params.text) == "string" then + for line in params.text:gmatch("[^\r\n]+") do + gems[#gems + 1] = line + end + end + if #gems == 0 then + util.fail("Provide 'gems' (list of gem names) or 'text'") + end + for _, gem in ipairs(gems) do + if type(gem) == "table" then + gem = ("%s %d/%d 1"):format(gem.name, gem.level or 20, gem.quality or 0) + elseif not gem:match("%d+/%d+") then + gem = gem .. " 20/0 1" + end + lines[#lines + 1] = gem + end + local before = #b.skillsTab.socketGroupList + b.skillsTab:PasteSocketGroup(table.concat(lines, "\n")) + if #b.skillsTab.socketGroupList == before then + util.fail("No gems were recognised") + end + local index = #b.skillsTab.socketGroupList + local info = groupInfo(b, index, b.skillsTab.socketGroupList[index]) + info.warnings = util.array() + for _, gem in ipairs(info.gems) do + if not gem.recognised then + local suggestions = M.suggest(gem.name) + info.warnings[#info.warnings + 1] = ("Unrecognised gem '%s'%s"):format(gem.name, + #suggestions > 0 and (". Did you mean: " .. table.concat(suggestions, ", ") .. "?") or "") + end + end + if params.makeMain or not b.mainSocketGroup or b.mainSocketGroup == 0 then + b.mainSocketGroup = index + end + session.recalc() + info.isMain = b.mainSocketGroup == index + return info +end + +-- Up to 5 gem names sharing a word with the given name. +function M.suggest(name) + local res = { } + for word in tostring(name):gmatch("%a%a%a+") do + for _, gem in pairs(data.gems) do + if #res < 5 and util.contains(gem.name, word) then + local dup = false + for _, r in ipairs(res) do dup = dup or r == gem.name end + if not dup then res[#res + 1] = gem.name end + end + end + end + return res +end + +function M.remove(params) + local b, _, index = getGroup(params) + table.remove(b.skillsTab.socketGroupList, index) + if b.mainSocketGroup >= index then + b.mainSocketGroup = math.max(1, b.mainSocketGroup - 1) + end + b.skillsTab:SetDisplayGroup(nil) + session.recalc() + return M.list() +end + +-- Change one gem in a group: params.index (1-based gem position), optional name, level, quality, enabled. +function M.set_gem(params) + local b, group, index = getGroup(params) + local gem = group.gemList[util.require(params, "index", "number")] + if not gem then + util.fail("No gem at index " .. tostring(params.index)) + end + if params.name then + local found + for id, gemData in pairs(data.gems) do + if gemData.name == params.name then + found = id + break + end + end + if not found then + local suggestions = M.suggest(params.name) + util.fail(("Unknown gem '%s'%s"):format(params.name, #suggestions > 0 and (". Did you mean: " .. table.concat(suggestions, ", ") .. "?") or "")) + end + gem.gemId = found + gem.nameSpec = data.gems[found].name + gem.skillId = data.gems[found].grantedEffectId + gem.level = params.level or b.skillsTab:ProcessGemLevel(data.gems[found]) + end + if params.level then gem.level = params.level end + if params.quality then gem.quality = params.quality end + if params.enabled ~= nil then gem.enabled = params.enabled end + b.skillsTab:ProcessSocketGroup(group) + session.recalc() + return groupInfo(b, index, group) +end + +function M.set_enabled(params) + local b, group, index = getGroup(params) + group.enabled = params.enabled ~= false + session.recalc() + return groupInfo(b, index, group) +end + +return M diff --git a/engine/pobapi/api/sockets.lua b/engine/pobapi/api/sockets.lua new file mode 100644 index 0000000000..9740475436 --- /dev/null +++ b/engine/pobapi/api/sockets.lua @@ -0,0 +1,387 @@ +-- sockets.*: rune, soul core and idol advice. What each socketed augment is worth, the best legal replacements per +-- socket, and a combined plan of swaps (greedy, re-measured after each accepted swap so caps and limits interact). +-- +-- Legality: PoB's own ItemsTab:GetValidRunesForItem (slot type, uniques, jewellery, soul-core/rune-only items), plus +-- what it leaves out: corrupted items, the character level requirement, per-item limits and character-wide limits +-- that share a limitId (e.g. "AncientAugment": Amanamu's Gaze and Emergent Possibility exclude each other). +local util = require("pobapi.util") +local session = require("pobapi.session") +local gear = require("pobapi.api.gear") + +local M = { } + +local DEAD = 0.05 -- percent of the metric + +-- Equipped items with augment sockets (weapon swap excluded). +local function socketedItems(b, only) + local res = { } + for _, slot in ipairs(b.itemsTab.orderedSlots) do + local name = slot.slotName + local item = b.itemsTab.items[slot.selItemId] + if item and (item.itemSocketCount or 0) > 0 and not name:match("Swap") and (not only or only == name) then + res[#res + 1] = { slot = name, item = item } + end + end + if only and #res == 0 then + util.fail("No socketed item in slot: " .. tostring(only)) + end + return res +end + +-- Data entries of a rune that apply to this item (same choice as Item:UpdateRunes). +local function applicableMods(item, name) + local rune = data.itemMods.Runes[name] + local res = { } + if not rune then + return res + end + local baseType, specificType = item:GetSocketedAugmentTypes() + if rune[baseType] then res[#res + 1] = rune[baseType] end + if specificType ~= baseType and rune[specificType] then res[#res + 1] = rune[specificType] end + for soulCoreType in pairs(item.socketedSoulCoreTypes or { }) do + local mod = rune[soulCoreType] + if mod and mod.type == "SoulCore" then res[#res + 1] = mod end + end + return res +end + +-- limit, limitId, level requirement and corruption rule of a rune on this item. +local function runeRules(item, name) + local rules = { limit = nil, limitId = nil, levelReq = 0, corruptOk = false, socketBound = false, lines = { }, bonded = { }, type = nil } + for _, mod in ipairs(applicableMods(item, name)) do + rules.type = rules.type or mod.type + rules.limit = mod.limit or rules.limit + rules.limitId = mod.limitId or rules.limitId + rules.levelReq = math.max(rules.levelReq, mod.levelReq or 0) + rules.corruptOk = rules.corruptOk or mod.canSocketInCorruptedSanctified or false + rules.socketBound = rules.socketBound or mod.isSocketBound or false + for _, line in ipairs(mod) do + rules.lines[#rules.lines + 1] = line + end + for _, line in ipairs(mod.bonded or { }) do + rules.bonded[#rules.bonded + 1] = line + end + end + return rules +end + +-- Lines PoB does not model: their effect is invisible to every number here. +local function unmodelled(lines) + local res = util.array() + for _, line in ipairs(lines) do + local list, extra = modLib.parseMod(line) + if not list or extra then + res[#res + 1] = line + end + end + return res +end + +-- Rune names in every equipped socket, for the character-wide limit check: { [item] = { [i] = name } }. +local function equippedRunes(entries) + local res = { } + for _, e in ipairs(entries) do + local list, ids = { }, { } + for i = 1, e.item.itemSocketCount do + list[i] = e.item.runes[i] or "None" + ids[i] = list[i] ~= "None" and runeRules(e.item, list[i]).limitId or nil + end + res[#res + 1] = { item = e.item, slot = e.slot, runes = list, limitIds = ids } + end + return res +end + +-- Can `name` go in socket `i` of `item`, given what is socketed everywhere else? When the only obstacle is a +-- character-wide limit held by other sockets, also returns those sockets (a swap there would free it). +local function legal(b, item, i, name, everywhere) + local rules = runeRules(item, name) + if rules.socketBound then + return false + end + if item.corrupted and not rules.corruptOk then + return false + end + if rules.levelReq > (b.characterLevel or 100) then + return false + end + if rules.limit or rules.limitId then + local sameItem, holders = 0, { } + for _, other in ipairs(everywhere) do + for j, otherName in ipairs(other.runes) do + if not (other.item == item and j == i) and otherName ~= "None" then + if other.item == item and otherName == name then + sameItem = sameItem + 1 + end + if rules.limitId and other.limitIds[j] == rules.limitId then + holders[#holders + 1] = { slot = other.slot, socket = j, name = otherName } + end + end + end + end + local limit = rules.limit or 1 + if not rules.limitId and sameItem >= limit then + return false + end + if rules.limitId and #holders >= limit then + return false, holders + end + end + return true +end + +-- A copy of `item` with socket `i` holding `name` ("None" empties it). +local function variant(item, i, name) + local copy = new("Item"):Item(item:BuildRaw()) + copy.runes[i] = name + copy:UpdateRunes() + copy:BuildAndParseRaw() + copy:NormaliseQuality() + copy:BuildModList() + return copy +end + +local function candidates(b, item) + local names = { } + for _, rune in ipairs(b.itemsTab:GetValidRunesForItem(item)) do + if rune.name ~= "None" then + names[#names + 1] = rune.name + end + end + return names +end + +local function row(name, item, r) + local rules = runeRules(item, name) + return { + name = name, + type = rules.type, + limitId = rules.limitId, + lines = rules.lines, + bonded = #rules.bonded > 0 and rules.bonded or nil, + unmodelled = (function() local u = unmodelled(rules.lines) return #u > 0 and u or nil end)(), + score = r.score, + dpsPercent = r.dpsPercent, + ehpPercent = r.ehpPercent, + totemsDelta = r.totemsDelta, + spiritDelta = r.spiritDelta, + } +end + +-- Every legal option for one socket, measured against the item as it is now (best first). Options blocked only by +-- a character-wide limit held elsewhere are measured too and carry `blockedBy`. `withEmpty` adds "None". +local function rankSocket(b, entry, i, everywhere, calcFunc, measure, names, withEmpty) + local item, cur = entry.item, entry.item.runes[i] or "None" + local options = { } + local list = names or candidates(b, item) + if withEmpty and cur ~= "None" then + list = { "None", unpack(list) } + end + for _, name in ipairs(list) do + local isLegal, blockedBy = true, nil + if name ~= "None" then + isLegal, blockedBy = legal(b, item, i, name, everywhere) + end + if name ~= cur and (isLegal or blockedBy) then + local ok, r = pcall(function() + return measure(calcFunc({ repSlotName = entry.slot, repItem = variant(item, i, name) })) + end) + if ok then + local opt + if name == "None" then + opt = { name = "None", lines = { }, score = r.score, dpsPercent = r.dpsPercent, ehpPercent = r.ehpPercent, + totemsDelta = r.totemsDelta, spiritDelta = r.spiritDelta } + else + opt = row(name, item, r) + end + opt.blockedBy = blockedBy + options[#options + 1] = opt + end + end + end + table.sort(options, function(x, y) return x.score > y.score end) + return options +end + +-- Per socket: the current augment's worth (vs an empty socket) and the best legal replacements. +function M.analyze(params) + params = params or { } + local b = session.build() + local metric = gear._metricOf(params) + local limit = tonumber(params.limit) or 5 + local calcFunc, _, measure = gear._measurer(b, metric) + local entries = socketedItems(b, params.slot) + local everywhere = equippedRunes(socketedItems(b)) -- limits count every equipped item, not just `slot` + local slots = util.array() + for _, entry in ipairs(entries) do + local item = entry.item + local sockets = util.array() + for i = 1, item.itemSocketCount do + local cur = item.runes[i] or "None" + local socket = { index = i, current = cur } + if cur ~= "None" then + local empty = measure(calcFunc({ repSlotName = entry.slot, repItem = variant(item, i, "None") })) + local current = row(cur, item, { score = -empty.score, dpsPercent = -empty.dpsPercent, ehpPercent = -empty.ehpPercent, + totemsDelta = empty.totemsDelta and -empty.totemsDelta, spiritDelta = empty.spiritDelta and -empty.spiritDelta }) + current.dead = math.abs(current.score) < DEAD and not current.unmodelled or nil + current.socketBound = runeRules(item, cur).socketBound or nil + socket.worth = current + end + if not (socket.worth and socket.worth.socketBound) then + local options = rankSocket(b, entry, i, everywhere, calcFunc, measure) + socket.legalOptions = #options + socket.alternatives = util.array() + for k = 1, math.min(limit, #options) do + socket.alternatives[k] = options[k] + end + for _, o in ipairs(options) do + if not o.blockedBy then + socket.bestGain = o.score + break + end + end + end + sockets[#sockets + 1] = socket + end + slots[#slots + 1] = { + slot = entry.slot, + item = item.name, + corrupted = item.corrupted or nil, + unique = (item.rarity == "UNIQUE" or item.rarity == "RELIC") or nil, + sockets = sockets, + } + end + return { + metric = metric, + slots = slots, + note = "Percent of the current build (DPS counts every totem the Spirit budget keeps up). 'unmodelled' lines are invisible to PoB, so their worth shows as 0.", + } +end + +-- Greedy plan: take the best swap, apply it, re-measure, repeat. The build is restored afterwards. +function M.optimize(params) + params = params or { } + local b = session.build() + local metric = gear._metricOf(params) + local maxSwaps = tonumber(params.maxSwaps) or 8 + local minGain = tonumber(params.minGain) or 0.5 + local shortlist = tonumber(params.shortlist) or 6 + -- Augments with lines PoB can't see are only swapped out to free a character-wide limit. + local keepUnmodelled = params.keepUnmodelled ~= false + local entries = socketedItems(b, params.slot) + local originals = { } + for _, entry in ipairs(entries) do + originals[#originals + 1] = { id = entry.item.id, item = entry.item } + end + local function restore() + for _, o in ipairs(originals) do + b.itemsTab.items[o.id] = o.item + end + session.recalc() + end + + local steps, total = util.array(), nil + local ok, err = pcall(function() + local short = { } -- per socket: names worth re-testing after the first full pass + for _ = 1, maxSwaps do + local calcFunc, _, measure = gear._measurer(b, metric) + local everywhere = equippedRunes(socketedItems(b)) + -- Rank every socket from the current state. + local ranked = { } + for _, entry in ipairs(entries) do + for i = 1, entry.item.itemSocketCount do + local key = entry.slot .. "#" .. i + local cur = entry.item.runes[i] or "None" + if not runeRules(entry.item, cur).socketBound then + local options = rankSocket(b, entry, i, everywhere, calcFunc, measure, short[key], true) + if not short[key] then + local names = { } + for _, o in ipairs(options) do + if #names >= shortlist then break end + if o.name ~= "None" then names[#names + 1] = o.name end + end + short[key] = names + end + local lost = cur ~= "None" and unmodelled(runeRules(entry.item, cur).lines) or { } + ranked[key] = { entry = entry, index = i, from = cur, options = options, lost = #lost > 0 and lost or nil } + end + end + end + -- Best move: one swap, or a swap blocked by a character-wide limit plus the swap that frees it. + local function bestFree(r, limitId) + for _, o in ipairs(r.options) do + if not o.blockedBy and (not limitId or o.limitId ~= limitId) then + return o + end + end + end + local best + for _, r in pairs(ranked) do + for _, o in ipairs(r.options) do + local move + if not o.blockedBy then + if not (keepUnmodelled and r.lost) then + move = { score = o.score, swaps = { { r = r, o = o } } } + end + elseif #o.blockedBy == 1 then + local h = o.blockedBy[1] + local other = ranked[h.slot .. "#" .. h.socket] + local free = other and bestFree(other, o.limitId) + if free then + move = { score = o.score + free.score, swaps = { { r = r, o = o }, { r = other, o = free } } } + end + end + if move and (not best or move.score > best.score) then + best = move + end + end + end + if not best or best.score < minGain then + break + end + for n, swap in ipairs(best.swaps) do + local r, o = swap.r, swap.o + steps[#steps + 1] = { + slot = r.entry.slot, socket = r.index, from = r.from, to = o.name, lines = o.lines, + unmodelled = o.unmodelled, losesUnmodelled = r.lost, + pairedWith = #best.swaps > 1 and (best.swaps[3 - n].r.entry.slot .. " socket " .. best.swaps[3 - n].r.index) or nil, + score = o.score, dpsPercent = o.dpsPercent, ehpPercent = o.ehpPercent, + totemsDelta = o.totemsDelta, spiritDelta = o.spiritDelta, + } + local newItem = variant(r.entry.item, r.index, o.name) + newItem.id = r.entry.item.id + b.itemsTab.items[newItem.id] = newItem + r.entry.item = newItem + short[r.entry.slot .. "#" .. r.index] = nil + end + session.recalc() + end + if #steps > 0 then + -- All steps together, measured against the original build. + local finalOut = b.calcsTab.mainOutput + restore() + local _, _, measureFromStart = gear._measurer(b, metric) + total = measureFromStart(finalOut) + end + end) + restore() + if not ok then + error(err, 0) + end + local warnings = util.array() + for _, s in ipairs(steps) do + if s.losesUnmodelled then + warnings[#warnings + 1] = ("%s socket %d: %s has effects PoB does not model (%s); weigh them before swapping"):format( + s.slot, s.socket, s.from, table.concat(s.losesUnmodelled, "; ")) + end + end + return { + metric = metric, + steps = steps, + total = total and { score = total.score, dpsPercent = total.dpsPercent, ehpPercent = total.ehpPercent, + totemsDelta = total.totemsDelta, spiritDelta = total.spiritDelta } or nil, + warnings = warnings, + note = "Each step is measured after the previous ones are applied; 'total' is all steps together vs your current build. Nothing is changed.", + } +end + +return M diff --git a/engine/pobapi/api/stats.lua b/engine/pobapi/api/stats.lua new file mode 100644 index 0000000000..dcfe2ffd23 --- /dev/null +++ b/engine/pobapi/api/stats.lua @@ -0,0 +1,201 @@ +-- stats.*: calculated output, sidebar stats and what-if comparisons. +local util = require("pobapi.util") +local session = require("pobapi.session") + +local M = { } + +local function statValue(statData, output) + local v = output[statData.stat] + if v ~= nil and statData.childStat then + v = type(v) == "table" and v[statData.childStat] or nil + end + return v +end + +local function scaled(statData, v) + return v * ((statData.pc or statData.mod) and 100 or 1) - (statData.mod and 100 or 0) +end + +-- Replicates the sidebar filtering in buildMode:AddDisplayStatList. +local function isShown(statData, actor, output, v) + if not statData.stat or statData.hideStat or statData.stat == "SkillDPS" then + return false + end + if statData.flag then + local flags = actor.mainSkill and actor.mainSkill.activeEffect.statSet.skillFlags or { } + if not flags[statData.flag] then + return false + end + end + if type(v) ~= "number" then + return false + end + if statData.condFunc then + local ok, res = pcall(statData.condFunc, v, output) + return ok and res and true or false + end + return v ~= 0 +end + +local function displayList(statList, actor, output) + local rows = util.array() + local seen = { } + for _, statData in ipairs(statList) do + local v = statValue(statData, output) + local key = statData.stat and statData.label and (statData.stat .. "|" .. statData.label) + if key and not seen[key] and isShown(statData, actor, output, v) then + seen[key] = true + local warn + if statData.warnFunc then + local ok, w = pcall(statData.warnFunc, v, output) + warn = ok and type(w) == "string" and w or nil + end + rows[#rows + 1] = { + stat = statData.stat, + childStat = statData.childStat, + label = statData.label, + value = scaled(statData, v), + formatted = util.strip(string.format("%" .. statData.fmt, scaled(statData, v))), + overCap = statData.overCapStat and output[statData.overCapStat] or nil, + lowerIsBetter = statData.lowerIsBetter or nil, + warning = warn, + } + end + end + return rows +end + +function M.summary() + local b = session.build() + local player = session.player() + local res = { player = displayList(b.displayStats, player, player.output) } + local minion = player.mainSkill and player.mainSkill.minion + if minion and minion.output then + res.minion = displayList(b.minionDisplayStats, minion, minion.output) + end + res.skillDPS = util.array() + for _, s in ipairs(player.output.SkillDPS or { }) do + res.skillDPS[#res.skillDPS + 1] = { name = s.name, dps = s.dps, count = s.count or 1, source = s.source, trigger = s.trigger } + end + return res +end + +-- Raw calculated output. Without keys only scalar values are returned. +function M.get(params) + local output = session.output() + local res = { } + if type(params.keys) == "table" and #params.keys > 0 then + for _, key in ipairs(params.keys) do + res[key] = output[key] + end + return res + end + for k, v in pairs(output) do + local t = type(v) + if t == "number" or t == "boolean" or t == "string" then + res[k] = v + end + end + return res +end + +-- Build a before/after comparison over the sidebar stats. +local function diff(base, new) + local b = session.build() + local player = session.player() + local rows = util.array() + local seen = { } + for _, statData in ipairs(b.displayStats) do + if statData.stat and statData.label and not statData.hideStat and statData.stat ~= "SkillDPS" then + local key = statData.stat .. "|" .. (statData.childStat or "") + local before, after = statValue(statData, base), statValue(statData, new) + if not seen[key] and type(before) == "number" and type(after) == "number" and before ~= after + and (isShown(statData, player, base, before) or isShown(statData, player, new, after)) then + seen[key] = true + local bv, av = scaled(statData, before), scaled(statData, after) + local better = (av > bv) ~= (statData.lowerIsBetter or false) + rows[#rows + 1] = { + stat = statData.stat, + label = statData.label, + before = bv, + after = av, + delta = av - bv, + percent = bv ~= 0 and (av - bv) / math.abs(bv) * 100 or nil, + better = better, + } + end + end + end + return rows +end +M.diff = diff + +local function nodesFromIds(ids) + local spec = session.build().spec + local set = { } + for _, id in ipairs(ids or { }) do + local node = spec.nodes[tonumber(id)] + if not node then + util.fail("Unknown passive node id: " .. tostring(id)) + end + set[node] = true + end + return set +end + +-- What-if for passive nodes. `add` includes the path needed to reach each node. +function M.compare_nodes(params) + local b = session.build() + local calcFunc, baseOutput = b.calcsTab:GetMiscCalculator() + local override = { } + if params.add and #params.add > 0 then + override.addNodes = { } + for node in pairs(nodesFromIds(params.add)) do + if not node.alloc then + for _, pathNode in ipairs(node.path or { node }) do + override.addNodes[pathNode] = true + end + end + end + end + if params.remove and #params.remove > 0 then + override.removeNodes = { } + for node in pairs(nodesFromIds(params.remove)) do + for _, dep in ipairs(node.depends or { node }) do + override.removeNodes[dep] = true + end + end + end + local pointDelta = 0 + for _ in pairs(override.addNodes or { }) do pointDelta = pointDelta + 1 end + for _ in pairs(override.removeNodes or { }) do pointDelta = pointDelta - 1 end + local newOutput = calcFunc(override) + return { pointDelta = pointDelta, changes = diff(baseOutput, newOutput), budget = require("pobapi.api.budget")._compare(baseOutput, newOutput) } +end + +-- What-if for an item. Slot defaults to the item's primary slot. +function M.compare_item(params) + local text = util.require(params, "text", "string") + local b = session.build() + local item = new("Item"):Item(text) + if not item.base then + util.fail("Could not parse item (unknown base type)") + end + item:NormaliseQuality() + item:BuildModList() + local slot = params.slot or item:GetPrimarySlot() + if not b.itemsTab.slots[slot] then + util.fail("Unknown slot: " .. tostring(slot)) + end + local calcFunc, baseOutput = require("pobapi.api.budget")._calculator(b) + local newOutput = calcFunc({ repSlotName = slot, repItem = item }) + return { + slot = slot, + item = item.name, + changes = diff(baseOutput, newOutput), + -- Spirit/totems/requirements and main-skill DPS across all totems (the sidebar rows don't show these). + budget = require("pobapi.api.budget")._compare(baseOutput, newOutput), + } +end + +return M diff --git a/engine/pobapi/api/tree.lua b/engine/pobapi/api/tree.lua new file mode 100644 index 0000000000..92709249f2 --- /dev/null +++ b/engine/pobapi/api/tree.lua @@ -0,0 +1,321 @@ +-- tree.*: passive tree state, search, allocation and layout for rendering. +local util = require("pobapi.util") +local session = require("pobapi.session") +local treelink = require("pobapi.treelink") + +local M = { } + +local function nodeInfo(node, withStats) + local res = { + id = node.id, + name = node.dn, + type = node.type, + ascendancy = node.ascendancyName, + allocated = node.alloc or false, + isAttribute = node.isAttribute or nil, + } + if withStats then + res.stats = util.array() + for _, line in ipairs(node.sd or { }) do + res.stats[#res.stats + 1] = util.strip(line) + end + if not node.alloc then + res.pathLength = node.path and #node.path or nil + end + end + return res +end + +function M.get() + local b = session.build() + local spec = b.spec + local used, ascUsed = spec:CountAllocNodes() + local nodes = util.array() + for _, node in pairs(spec.allocNodes) do + nodes[#nodes + 1] = nodeInfo(node, true) + end + table.sort(nodes, function(a, c) return a.id < c.id end) + return { + treeVersion = spec.treeVersion, + className = spec.curClassName, + ascendClassName = spec.curAscendClassName, + pointsUsed = used, + ascendancyPointsUsed = ascUsed, + nodes = nodes, + } +end + +-- Search nodes by name or stat text. Ignores small attribute nodes unless includeMinor. +function M.search(params) + local query = util.require(params, "query", "string") + local limit = params.limit or 25 + local spec = session.build().spec + local res = util.array() + for _, node in pairs(spec.nodes) do + if node.dn and node.type ~= "ClassStart" and node.type ~= "AscendClassStart" and node.type ~= "OnlyImage" + and (params.includeMinor or node.type ~= "Normal" or node.ascendancyName) then + local match = util.contains(node.dn, query) + if not match then + for _, line in ipairs(node.sd or { }) do + if util.contains(line, query) then + match = true + break + end + end + end + if match and (not node.ascendancyName or node.ascendancyName == spec.curAscendClassName or params.anyAscendancy) then + res[#res + 1] = nodeInfo(node, true) + end + end + end + table.sort(res, function(a, c) + if a.allocated ~= c.allocated then return not a.allocated end + return (a.pathLength or 999) < (c.pathLength or 999) + end) + while #res > limit do + table.remove(res) + end + return res +end + +function M.node(params) + local id = util.require(params, "id", "number") + local node = session.build().spec.nodes[id] + if not node then + util.fail("Unknown node id: " .. id) + end + return nodeInfo(node, true) +end + +local function eachNode(params) + local spec = session.build().spec + local list = { } + for _, id in ipairs(util.require(params, "ids", "table")) do + local node = spec.nodes[tonumber(id)] + if not node then + util.fail("Unknown node id: " .. tostring(id)) + end + list[#list + 1] = node + end + return spec, list +end + +function M.allocate(params) + local spec, list = eachNode(params) + local failed = util.array() + for _, node in ipairs(list) do + if not node.alloc then + spec:AllocNode(node) + if not node.alloc then + failed[#failed + 1] = node.id + end + end + end + session.recalc() + local res = M.get() + res.failed = failed + return res +end + +function M.deallocate(params) + local spec, list = eachNode(params) + for _, node in ipairs(list) do + if node.alloc then + spec:DeallocNode(node) + end + end + session.recalc() + return M.get() +end + +function M.reset() + local spec = session.build().spec + spec:ResetNodes() + spec:BuildAllDependsAndPaths() + session.recalc() + return M.get() +end + +-- Choose the +5 attribute on attribute nodes: changes = { { id = , attribute = "Strength"|"Dexterity"|"Intelligence" } }. +function M.set_attributes(params) + local spec = session.build().spec + for _, change in ipairs(util.require(params, "changes", "table")) do + local id = tonumber(change.id) + local treeNode = id and spec.tree.nodes[id] + if not (treeNode and treeNode.isAttribute and treeNode.options) then + util.fail("Not an attribute node: " .. tostring(change.id)) + end + local index + for i, option in ipairs(treeNode.options) do + if (option.dn or option.name) == change.attribute then + index = i + end + end + if not index then + util.fail("Unknown attribute: " .. tostring(change.attribute)) + end + spec:SwitchAttributeNode(id, index) + spec:ReplaceNode(spec.nodes[id], spec.hashOverrides[id]) + end + session.recalc() + return M.get() +end + +function M.export_url() + local code = treelink.encode(session.build().spec) + return { url = treelink.PREFIX .. code, code = code } +end + +function M.import_url(params) + local url = util.require(params, "url", "string") + local err = treelink.decode(session.build().spec, url) + if err then + util.fail("Invalid tree URL: " .. tostring(err)) + end + session.recalc() + return M.get() +end + +local function round(v) + return math.floor(v * 10 + 0.5) / 10 +end + +local function width(size) + return size and size.width or nil +end + +-- Orbit of the connection between two nodes, oriented from a to b. +local function connectionOrbit(tree, a, b) + for _, c in ipairs(tree.nodes[a.id] and tree.nodes[a.id].connections or { }) do + if c.id == b.id then + return c.orbit or 0 + end + end + for _, c in ipairs(tree.nodes[b.id] and tree.nodes[b.id].connections or { }) do + if c.id == a.id then + return -(c.orbit or 0) + end + end + return 0 +end + +-- Circle (cx, cy, r) a connection follows, mirroring PassiveTree:BuildConnector. nil for straight lines. +local function connectionArc(tree, n1, n2, orbit) + local s = tree.scaleImage + if orbit ~= 0 and tree.orbitRadii[math.abs(orbit) + 1] then + local r = tree.orbitRadii[math.abs(orbit) + 1] * s + local dx, dy = n2.x - n1.x, n2.y - n1.y + local dist = math.sqrt(dx * dx + dy * dy) + if dist > 0 and dist < r * 2 then + local perp = math.sqrt(r * r - dist * dist / 4) * (orbit > 0 and 1 or -1) + return n1.x + dx / 2 + perp * (dy / dist), n1.y + dy / 2 - perp * (dx / dist), r + end + elseif n1.group and n1.group == n2.group and n1.o and n1.o > 0 and n1.o == n2.o then + return n1.group.x * s, n1.group.y * s, tree.orbitRadii[n1.o + 1] * s + end +end + +-- Geometry and artwork for rendering the tree. Asset names resolve through /tree-art//manifest.json. +function M.layout() + local spec = session.build().spec + local tree = spec.tree + local s = tree.scaleImage + local nodes = util.array() + local edges = util.array() + local decorations = util.array() + for id, node in pairs(spec.nodes) do + -- Base tree node: spec.nodes may hold a per-build attribute override. + local treeNode = tree.nodes[id] or node + if node.x and node.group and node.type == "OnlyImage" then + if node.activeEffectImage then + decorations[#decorations + 1] = { x = round(node.x), y = round(node.y), image = node.activeEffectImage, r = width(node.targetSize) } + end + elseif node.x and node.group then + local size = node.targetSize or { } + local entry = { + id = id, + x = round(node.x), + y = round(node.y), + type = node.type, + name = treeNode.dn or node.dn, + ascendancy = node.ascendancyName, + alloc = node.alloc or nil, + icon = treeNode.icon, + r = size.width, + fr = width(size.overlay), + effect = treeNode.activeEffectImage, + er = width(size.effect), + } + if node.type == "AscendClassStart" then + entry.frame = { "AscendancyMiddle", "AscendancyMiddle", "AscendancyMiddle" } + elseif node.overlay then + entry.frame = { node.overlay.unalloc, node.overlay.path, node.overlay.alloc } + end + if treeNode.isAttribute and treeNode.options then + entry.options = util.array() + for _, option in ipairs(treeNode.options) do + entry.options[#entry.options + 1] = { name = option.name, icon = option.icon } + end + end + nodes[#nodes + 1] = entry + for _, other in ipairs(node.linked or { }) do + if other.id > id and other.x and other.group and other.type ~= "OnlyImage" and (node.ascendancyName == other.ascendancyName) then + local cx, cy, r = connectionArc(tree, node, other, connectionOrbit(tree, node, other)) + edges[#edges + 1] = cx and { id, other.id, round(cx), round(cy), round(r) } or { id, other.id } + end + end + end + end + local classes = util.array() + for classId, class in pairs(tree.classes) do + local bg = class.background + if bg then + local ascendancyImages = { } + for ascendClassId, ascendClass in pairs(class.classes or { }) do + if ascendClassId ~= 0 and ascendClass.background then + ascendancyImages[ascendClass.id or ascendClass.name] = ascendClass.background.image + end + end + classes[#classes + 1] = { + id = classId, + name = class.name, + startNode = class.startNodeId, + image = bg.image, + ascendancyImages = ascendancyImages, + x = round(bg.x * s), + y = round(bg.y * s), + w = bg.width, + h = bg.height, + treeW = width(bg.bg), + activeW = width(bg.active), + } + end + end + local ascendancies = util.array() + for name, data in pairs(tree.ascendNameMap) do + local bg = data.ascendClass.background + if bg then + ascendancies[#ascendancies + 1] = { + name = name, + className = data.class.name, + image = bg.image, + x = round(bg.x * s), + y = round(bg.y * s), + w = bg.width, + h = bg.height, + replace = data.ascendClass.replace, + replaceBy = data.ascendClass.replaceBy, + } + end + end + return { + treeVersion = spec.treeVersion, + nodes = nodes, + edges = edges, + decorations = decorations, + classes = classes, + ascendancies = ascendancies, + } +end + +return M diff --git a/engine/pobapi/boot.lua b/engine/pobapi/boot.lua new file mode 100644 index 0000000000..92f5521a4f --- /dev/null +++ b/engine/pobapi/boot.lua @@ -0,0 +1,41 @@ +-- Boots Path of Building headless. Mirrors src/HeadlessWrapper.lua without blocking on stdin. +local M = {} + +function M.start() + dofile("_SimpleGraphic.def.lua") + + function GetVirtualScreenSize() + return 1920, 1080 + end + + __callbackTable__ = { } + function runCallback(name, ...) + if __callbackTable__[name] then + return __callbackTable__[name](...) + elseif __mainObject__ and __mainObject__[name] then + return __mainObject__[name](__mainObject__, ...) + end + end + + local l_require = require + function require(name) + -- lcurl is only needed for network features, which the worker does not use + if name == "lcurl.safe" then + return + end + return l_require(name) + end + + dofile("Launch.lua") + __mainObject__.continuousIntegrationMode = os.getenv("CI") + runCallback("OnInit") + runCallback("OnFrame") + + if __mainObject__.promptMsg then + error("PoB failed to start: " .. tostring(__mainObject__.promptMsg)) + end + + build = __mainObject__.main.modes["BUILD"] +end + +return M diff --git a/engine/pobapi/registry.lua b/engine/pobapi/registry.lua new file mode 100644 index 0000000000..09111b468e --- /dev/null +++ b/engine/pobapi/registry.lua @@ -0,0 +1,18 @@ +-- Maps RPC method names ("namespace.method") to handler functions. +local registry = { } + +local namespaces = { "build", "stats", "skills", "items", "tree", "config", "data", "reference", "advisor", "gear", "gems", "budget", "sockets" } +for _, ns in ipairs(namespaces) do + local mod = require("pobapi.api." .. ns) + for name, fn in pairs(mod) do + if type(fn) == "function" and name:sub(1, 1) ~= "_" and name ~= "describe" and name ~= "diff" and name ~= "suggest" then + registry[ns .. "." .. name] = fn + end + end +end + +registry["worker.ping"] = function() + return { pong = true, version = launch.versionNumber, time = os.time() } +end + +return registry diff --git a/engine/pobapi/rpc.lua b/engine/pobapi/rpc.lua new file mode 100644 index 0000000000..006e48e000 --- /dev/null +++ b/engine/pobapi/rpc.lua @@ -0,0 +1,69 @@ +-- Newline-delimited JSON-RPC transport. +-- Request: {"id": 1, "method": "ns.name", "params": {...}} +-- Response: {"id": 1, "result": ...} | {"id": 1, "error": {"message": "...", "traceback": "..."}} +local json = require("dkjson") +local util = require("pobapi.util") + +local M = {} +local out + +-- Reserve the real stdout for protocol frames; route everything else to stderr. +function M.captureStdout() + out = io.stdout + io.output(io.stderr) + print = function(...) + local parts = { } + for i = 1, select("#", ...) do + parts[i] = tostring((select(i, ...))) + end + io.stderr:write(table.concat(parts, "\t"), "\n") + end +end + +function M.send(msg) + out:write(json.encode(util.sanitize(msg)), "\n") + out:flush() +end + +local function dispatch(handlers, req) + local handler = handlers[req.method] + if not handler then + return nil, { message = "Unknown method: " .. tostring(req.method), code = "method_not_found" } + end + local tb + local ok, result = xpcall(function() return handler(req.params or { }) end, function(e) + tb = debug.traceback(e, 2) + return e + end) + if ok then + return result == nil and json.null or result + end + if type(result) == "table" and result.apiError then + return nil, { message = result.message, code = result.code } + end + return nil, { message = tostring(result), code = "internal", traceback = tb } +end + +function M.serve(handlers) + M.send({ event = "ready", methods = util.sortedKeys(handlers) }) + for line in io.stdin:lines() do + if line:match("%S") then + local req, _, decodeErr = json.decode(line, 1, nil) + if not req or type(req) ~= "table" then + M.send({ id = json.null, error = { message = "Invalid JSON: " .. tostring(decodeErr), code = "parse_error" } }) + elseif req.method == "worker.shutdown" then + M.send({ id = req.id, result = true }) + return + else + local result, err = dispatch(handlers, req) + if err then + M.send({ id = req.id, error = err }) + else + M.send({ id = req.id, result = result }) + end + end + end + end +end + +return M diff --git a/engine/pobapi/session.lua b/engine/pobapi/session.lua new file mode 100644 index 0000000000..415a50a28f --- /dev/null +++ b/engine/pobapi/session.lua @@ -0,0 +1,38 @@ +-- Access to the live build object and recalculation helpers. +local M = {} + +function M.build() + return __mainObject__.main.modes["BUILD"] +end + +-- Run one frame so PoB processes pending flags (buildFlag, mode changes, recalculation). +function M.frame() + runCallback("OnFrame") + build = M.build() + return build +end + +-- Mark the build dirty and recalculate. +function M.recalc() + local b = M.build() + b.buildFlag = true + return M.frame() +end + +function M.setMode(name, xml) + if GlobalCache and GlobalCache.cachedData then + wipeGlobalCache() + end + __mainObject__.main:SetMode("BUILD", false, name or "", xml) + return M.frame() +end + +function M.player() + return M.build().calcsTab.mainEnv.player +end + +function M.output() + return M.build().calcsTab.mainOutput +end + +return M diff --git a/engine/pobapi/treelink.lua b/engine/pobapi/treelink.lua new file mode 100644 index 0000000000..5a6b659c70 --- /dev/null +++ b/engine/pobapi/treelink.lua @@ -0,0 +1,183 @@ +-- PoE2 passive tree links (pathofexile2.com/game/passive-skill-tree/), format version 7. +-- Mirrors GGG's web planner: u32 version, u8 class, u8 ascendancy, u16 count, then per node +-- u16 hash, u16 flags [, u8 allocation type if flags & 1] [, u16 attribute override id if flags & 2]. +-- Older (v4-v6, PoE1-era) codes are delegated to PoB's PassiveSpec:DecodeURL. +local M = { } + +M.PREFIX = "https://pathofexile2.com/game/passive-skill-tree/" + +local VERSION = 7 +local FLAG_ALLOC_TYPE = 1 +local FLAG_OVERRIDE = 2 +-- GGG allocation types +local BASE, ASCENDANCY, WEAPON_SET_1 = 0, 1, 2 + +local function u16(n) + return string.char(math.floor(n / 256) % 256, n % 256) +end + +-- Attribute nodes (+5 Str/Dex/Int choice) store GGG's option id as the override. The ids are the same on every +-- attribute node; PoB overwrites option ids with the node id while loading the tree, so they are listed here. +local ATTRIBUTE_OPTION_IDS = { Strength = 26297, Dexterity = 14927, Intelligence = 57022 } + +local function overrideId(spec, id, node) + if spec.hashOverrides and spec.hashOverrides[id] and node.isAttribute then + return ATTRIBUTE_OPTION_IDS[node.dn] + end +end + +-- GGG's planner replays the list in order and only counts a node toward its point pool if it touches an +-- already-loaded node of a compatible pool, so emit nodes outward from the start nodes: base tree from the +-- class start, then weapon-set nodes, then the ascendancy from its start node. +local function connectedOrder(spec, ids) + local wanted, seen, order = { }, { }, { } + for _, id in ipairs(ids) do + wanted[id] = true + end + local function walk(roots, accept) + local queue, head = { }, 1 + for _, node in ipairs(roots) do + queue[#queue + 1] = node + end + while head <= #queue do + local node = queue[head] + head = head + 1 + for _, other in ipairs(node.linked or { }) do + if wanted[other.id] and not seen[other.id] and accept(other) then + seen[other.id] = true + order[#order + 1] = other.id + queue[#queue + 1] = other + end + end + end + end + local class = spec.tree.classes[spec.curClassId] + local roots = { class and spec.nodes[class.startNodeId] } + walk(roots, function(node) return not node.ascendancyName and (node.allocMode or 0) == 0 end) + for _, id in ipairs(order) do + roots[#roots + 1] = spec.nodes[id] + end + walk(roots, function(node) return not node.ascendancyName end) + for _, node in pairs(spec.allocNodes) do + if node.type == "AscendClassStart" then + walk({ node }, function(other) return other.ascendancyName ~= nil end) + end + end + for _, id in ipairs(ids) do + if not seen[id] then + order[#order + 1] = id + end + end + return order +end + +function M.encode(spec) + local ids = { } + for id, node in pairs(spec.allocNodes) do + if node.type ~= "ClassStart" and node.type ~= "AscendClassStart" and id < 65536 then + ids[#ids + 1] = id + end + end + table.sort(ids) + ids = connectedOrder(spec, ids) + local parts = { string.char(0, 0, 0, VERSION, spec.curClassId or 0, spec.curAscendClassId or 0), u16(#ids) } + for _, id in ipairs(ids) do + local node = spec.allocNodes[id] + local allocType = BASE + if node.ascendancyName then + allocType = ASCENDANCY + elseif (node.allocMode or 0) > 0 then + allocType = WEAPON_SET_1 + node.allocMode - 1 + end + local override = overrideId(spec, id, node) + local flags = (allocType ~= BASE and FLAG_ALLOC_TYPE or 0) + (override and FLAG_OVERRIDE or 0) + parts[#parts + 1] = u16(id) .. u16(flags) + if allocType ~= BASE then + parts[#parts + 1] = string.char(allocType) + end + if override then + parts[#parts + 1] = u16(override) + end + end + return (common.base64.encode(table.concat(parts)):gsub("+", "-"):gsub("/", "_")) +end + +local function codeFromUrl(url) + local code = url:gsub("[?#].*$", ""):gsub("/+$", ""):gsub("^.*/", "") + code = code:gsub("%%(%x%x)", function(hex) return string.char(tonumber(hex, 16)) end) + return (code:gsub("-", "+"):gsub("_", "/")) +end + +-- Returns nil on success, or an error message. +function M.decode(spec, url) + local b = common.base64.decode(codeFromUrl(url)) + if not b or #b < 8 then + return "unrecognised format" + end + local version = b:byte(1) * 16777216 + b:byte(2) * 65536 + b:byte(3) * 256 + b:byte(4) + if version < VERSION then + return spec:DecodeURL(url) + elseif version > VERSION then + return "unknown version number " .. version + end + local classId, ascendClassId = b:byte(5), b:byte(6) + local pos = 9 + local function read(n) + if pos + n - 1 > #b then + error("truncated tree link", 0) + end + local v = 0 + for i = pos, pos + n - 1 do + v = v * 256 + b:byte(i) + end + pos = pos + n + return v + end + local hashes, weaponSets, overrides = { }, { }, { } + local ok, err = pcall(function() + if b:byte(7) * 256 + b:byte(8) == 0 then + return + end + while pos <= #b do + local id, flags = read(2), read(2) + local allocType = BASE + if flags % 2 == 1 then + allocType = read(1) + end + if math.floor(flags / 2) % 2 == 1 then + overrides[id] = read(2) + end + hashes[#hashes + 1] = id + if allocType >= WEAPON_SET_1 then + weaponSets[id] = allocType - WEAPON_SET_1 + 1 + end + end + end) + if not ok then + return err + end + -- GGG links always describe the live game tree. + local tree = main:LoadTree(latestTreeVersion) + if not tree.classes[classId] then + return "bad class id " .. classId + end + if ascendClassId ~= 0 and not tree.classes[classId].classes[ascendClassId] then + return "bad ascendancy id " .. ascendClassId + end + spec:ImportFromNodeList(nil, classId, ascendClassId, 0, hashes, weaponSets, { }, { }, latestTreeVersion) + for id, optionId in pairs(overrides) do + local treeNode = spec.tree.nodes[id] + for index, option in ipairs(treeNode and treeNode.options or { }) do + if ATTRIBUTE_OPTION_IDS[option.dn or option.name] == optionId then + spec:SwitchAttributeNode(id, index) + if spec.nodes[id] then + spec:ReplaceNode(spec.nodes[id], spec.hashOverrides[id]) + end + break + end + end + end + spec:BuildAllDependsAndPaths() +end + +return M diff --git a/engine/pobapi/util.lua b/engine/pobapi/util.lua new file mode 100644 index 0000000000..d66ab40b91 --- /dev/null +++ b/engine/pobapi/util.lua @@ -0,0 +1,82 @@ +-- Shared helpers for the API layer. +local json = require("dkjson") + +local M = {} + +-- Raise an error that is reported to the client as a clean message (no traceback). +function M.fail(message, code) + error({ apiError = true, message = message, code = code or "bad_request" }, 0) +end + +function M.require(params, key, kind) + local v = params[key] + if v == nil then + M.fail("Missing parameter: " .. key) + end + if kind and type(v) ~= kind then + M.fail(("Parameter '%s' must be a %s"):format(key, kind)) + end + return v +end + +-- Remove SimpleGraphic colour escapes (^7, ^xRRGGBB). +function M.strip(text) + if type(text) ~= "string" then + return text + end + return (text:gsub("%^x%x%x%x%x%x%x", ""):gsub("%^%d", "")) +end + +function M.sortedKeys(t) + local keys = { } + for k in pairs(t) do + keys[#keys + 1] = k + end + table.sort(keys) + return keys +end + +-- Make a value JSON-safe: drop functions/userdata, replace NaN/inf, break cycles. +function M.sanitize(v, seen) + local t = type(v) + if t == "number" then + if v ~= v then return json.null end + if v == math.huge then return 1e308 end + if v == -math.huge then return -1e308 end + return v + elseif t == "string" or t == "boolean" or v == json.null then + return v + elseif t == "table" then + seen = seen or { } + if seen[v] then return json.null end + seen[v] = true + local res = { } + for k, val in pairs(v) do + local kt = type(k) + if kt == "string" or kt == "number" then + local sv = M.sanitize(val, seen) + if sv ~= nil then + res[k] = sv + end + end + end + seen[v] = nil + local mt = getmetatable(v) + if type(mt) == "table" and mt.__jsontype then + setmetatable(res, { __jsontype = mt.__jsontype }) + end + return res + end + return nil +end + +-- Mark a table so dkjson always encodes it as an array (even when empty). +function M.array(t) + return setmetatable(t or { }, { __jsontype = "array" }) +end + +function M.contains(haystack, needle) + return type(haystack) == "string" and haystack:lower():find(needle:lower(), 1, true) ~= nil +end + +return M diff --git a/engine/worker.lua b/engine/worker.lua new file mode 100644 index 0000000000..43844a1808 --- /dev/null +++ b/engine/worker.lua @@ -0,0 +1,16 @@ +-- PoB2-Enhanced headless engine worker. +-- Speaks newline-delimited JSON-RPC over stdin/stdout. All PoB console output goes to stderr. +-- Must be started with the working directory set to src/: luajit ../engine/worker.lua +local engineDir = (arg and arg[0] or ""):match("^(.*)[/\\]worker%.lua$") or "../engine" +package.path = engineDir .. "/?.lua;../runtime/lua/?.lua;../runtime/lua/?/init.lua;" .. package.path + +local rpc = require("pobapi.rpc") +rpc.captureStdout() + +local ok, err = xpcall(function() require("pobapi.boot").start() end, debug.traceback) +if not ok then + rpc.send({ event = "fatal", error = { message = tostring(err) } }) + os.exit(1) +end + +rpc.serve(require("pobapi.registry")) diff --git a/python/.gitignore b/python/.gitignore new file mode 100644 index 0000000000..847b04b679 --- /dev/null +++ b/python/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +# Built by `npm run build` in web/ +src/pob2/web/static/ diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000..f139512d23 --- /dev/null +++ b/python/README.md @@ -0,0 +1,3 @@ +# pob2-enhanced + +Python bridge to the headless Path of Building 2 engine. See [docs/enhanced.md](../docs/enhanced.md). diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000000..e607872a21 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "pob2-enhanced" +version = "0.1.0" +description = "Headless Path of Building 2 engine bridge: MCP server, REST API and web UI." +readme = "README.md" +license = "MIT" +requires-python = ">=3.10" +dependencies = [ + "mcp>=2.2,<3", + "fastapi>=0.115", + "uvicorn>=0.30", + "httpx>=0.27", +] + +[project.scripts] +pob2 = "pob2.cli:main" +pob2-mcp = "pob2.mcp_server:main" +pob2-web = "pob2.web.app:main" + +[dependency-groups] +dev = [ + "pytest>=8", + "ruff>=0.6", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pob2"] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["B008"] # FastAPI Query()/Depends() defaults + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = ["engine: requires LuaJIT (2026-07-20+) and lua-utf8; skipped when unavailable"] diff --git a/python/src/pob2/__init__.py b/python/src/pob2/__init__.py new file mode 100644 index 0000000000..03cbd03b97 --- /dev/null +++ b/python/src/pob2/__init__.py @@ -0,0 +1,7 @@ +"""PoB2-Enhanced: headless Path of Building 2 for AI agents and the web.""" + +from pob2.engine import Engine, EngineError +from pob2.service import PoBService + +__all__ = ["Engine", "EngineError", "PoBService"] +__version__ = "0.1.0" diff --git a/python/src/pob2/cli.py b/python/src/pob2/cli.py new file mode 100644 index 0000000000..bd8d052839 --- /dev/null +++ b/python/src/pob2/cli.py @@ -0,0 +1,90 @@ +"""Command-line access to the headless engine. Prints JSON, so it composes with jq / ConvertFrom-Json. + +Examples: + pob2 overview --build + pob2 stats --build build.xml --keys TotalDPS Life + pob2 call tree.search '{"query": "spell damage"}' --build + pob2 decode > build.xml + pob2 methods +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from pob2 import codec +from pob2.engine import EngineError +from pob2.service import PoBService + + +def _source(value: str) -> str: + path = Path(value) + if len(value) < 1024 and path.is_file(): + return path.read_text(encoding="utf-8") + return value + + +def _emit(data: Any) -> None: + json.dump(data, sys.stdout, indent=2, ensure_ascii=False) + sys.stdout.write("\n") + + +def _with_build(args: argparse.Namespace) -> PoBService: + service = PoBService() + if getattr(args, "build", None): + service.load(_source(args.build)) + return service + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="pob2", description="Headless Path of Building 2") + sub = parser.add_subparsers(dest="cmd", required=True) + + def with_build(p: argparse.ArgumentParser) -> argparse.ArgumentParser: + p.add_argument("--build", "-b", help="Share code, pobb.in link, or path to a build XML file") + return p + + with_build(sub.add_parser("overview", help="Build info, stats, skills, items")) + stats = with_build(sub.add_parser("stats", help="Calculated stats")) + stats.add_argument("--keys", nargs="*", help="Output keys, e.g. TotalDPS Life") + call = with_build(sub.add_parser("call", help="Call any worker method")) + call.add_argument("method", help="e.g. tree.search (see `pob2 methods`)") + call.add_argument("params", nargs="?", default="{}", help="JSON object of parameters") + sub.add_parser("methods", help="List worker methods") + dec = sub.add_parser("decode", help="Share code / link -> XML") + dec.add_argument("source") + enc = sub.add_parser("encode", help="XML file -> share code") + enc.add_argument("file") + + args = parser.parse_args(argv) + try: + if args.cmd == "decode": + sys.stdout.write(codec.resolve(_source(args.source)) + "\n") + elif args.cmd == "encode": + sys.stdout.write(codec.encode_code(Path(args.file).read_text(encoding="utf-8")) + "\n") + else: + service = _with_build(args) + try: + if args.cmd == "overview": + _emit(service.overview()) + elif args.cmd == "stats": + _emit(service.call("stats.get", keys=args.keys)) + elif args.cmd == "call": + _emit(service.call(args.method, **json.loads(args.params))) + elif args.cmd == "methods": + service.engine.start() + _emit(service.engine.methods) + finally: + service.engine.stop() + except (EngineError, codec.CodecError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/src/pob2/codec.py b/python/src/pob2/codec.py new file mode 100644 index 0000000000..9fa4a0654d --- /dev/null +++ b/python/src/pob2/codec.py @@ -0,0 +1,68 @@ +"""Path of Building share codes and build links.""" + +from __future__ import annotations + +import base64 +import re +import zlib + +import httpx + +# Paste sites that expose the raw share code at a predictable URL. +_RAW_URLS: list[tuple[re.Pattern[str], str]] = [ + (re.compile(r"^https?://pobb\.in/(?:u/[^/]+/)?([\w-]+)/?$"), "https://pobb.in/{}/raw"), + (re.compile(r"^https?://pastebin\.com/(?:raw/)?(\w+)/?$"), "https://pastebin.com/raw/{}"), + (re.compile(r"^https?://poe\.ninja/poe2/pob/(\w+)/?$"), "https://poe.ninja/poe2/pob/raw/{}"), +] + + +class CodecError(ValueError): + """Raised when a build code, link or XML cannot be decoded.""" + + +def decode_code(code: str) -> str: + """Share code (URL-safe base64 of zlib-deflated XML) -> build XML.""" + cleaned = re.sub(r"\s+", "", code).replace("-", "+").replace("_", "/") + cleaned += "=" * (-len(cleaned) % 4) + try: + return zlib.decompress(base64.b64decode(cleaned)).decode("utf-8") + except (ValueError, zlib.error) as exc: + raise CodecError(f"Not a valid Path of Building code: {exc}") from exc + + +def encode_code(xml: str) -> str: + """Build XML -> share code, matching PoB's own export format.""" + return base64.b64encode(zlib.compress(xml.encode("utf-8"), 9)).decode().replace("+", "-").replace("/", "_") + + +def raw_url(link: str) -> str | None: + for pattern, template in _RAW_URLS: + match = pattern.match(link.strip()) + if match: + return template.format(match.group(1)) + return None + + +def resolve(source: str, *, timeout: float = 15.0) -> str: + """Accept XML, a share code, or a supported paste link and return build XML.""" + text = source.strip() + if text.startswith("<"): + return text + if text.startswith(("http://", "https://")): + from pob2 import market # noqa: PLC0415 - avoid importing httpx-heavy helpers for plain codes + + if market.is_character_url(text): + try: + return decode_code(market.character_code(text)[0]) + except ValueError as exc: + raise CodecError(str(exc)) from exc + url = raw_url(text) + if not url: + raise CodecError( + "Unsupported link. Supported: pobb.in, pastebin.com, poe.ninja/poe2/pob, and poe.ninja character " + "pages (poe2/builds/... or poe2/profile/...)." + ) + resp = httpx.get(url, timeout=timeout, follow_redirects=True) + resp.raise_for_status() + text = resp.text.strip() + return decode_code(text) diff --git a/python/src/pob2/engine.py b/python/src/pob2/engine.py new file mode 100644 index 0000000000..5cc2f95e82 --- /dev/null +++ b/python/src/pob2/engine.py @@ -0,0 +1,190 @@ +"""Manages the LuaJIT headless worker process (engine/worker.lua) and its JSON-lines protocol.""" + +from __future__ import annotations + +import itertools +import json +import logging +import os +import queue +import shutil +import subprocess +import threading +from collections import deque +from pathlib import Path +from typing import Any + +log = logging.getLogger("pob2.engine") + +DEFAULT_START_TIMEOUT = 120.0 +DEFAULT_CALL_TIMEOUT = 120.0 + + +class EngineError(RuntimeError): + """Error reported by the worker, or a failure to talk to it.""" + + def __init__(self, message: str, code: str = "internal", traceback: str | None = None): + super().__init__(message) + self.code = code + self.traceback = traceback + + +def find_repo_root(start: Path | None = None) -> Path: + """Locate the repository root (the directory containing src/Launch.lua and engine/).""" + env = os.environ.get("POB2_ROOT") + candidates = [Path(env)] if env else [] + for base in (start, Path(__file__).resolve(), Path.cwd()): + if base: + candidates.extend([base, *base.parents]) + for path in candidates: + if (path / "src" / "Launch.lua").is_file() and (path / "engine" / "worker.lua").is_file(): + return path + raise EngineError("Could not find the PoB2-Enhanced repository. Set POB2_ROOT to the repo root.", "config") + + +def find_luajit() -> str: + exe = os.environ.get("POB2_LUAJIT") or shutil.which("luajit") + if not exe: + raise EngineError( + "LuaJIT not found. Run scripts/setup-engine.sh (or set POB2_LUAJIT). " + "PoB needs LuaJIT built from commit 2460b3ff (2026-07-20) or newer.", + "config", + ) + return exe + + +class Engine: + """A single PoB build session backed by one worker process. Thread-safe; calls are serialised.""" + + def __init__( + self, + root: Path | None = None, + luajit: str | None = None, + start_timeout: float = DEFAULT_START_TIMEOUT, + call_timeout: float = DEFAULT_CALL_TIMEOUT, + ): + self.root = root or find_repo_root() + self.luajit = luajit or find_luajit() + self.start_timeout = start_timeout + self.call_timeout = call_timeout + self.methods: list[str] = [] + self._proc: subprocess.Popen[str] | None = None + self._lines: queue.Queue[str | None] = queue.Queue() + self._stderr: deque[str] = deque(maxlen=200) + self._ids = itertools.count(1) + self._lock = threading.RLock() + + # -- lifecycle ------------------------------------------------------------------------------- + + @property + def running(self) -> bool: + return self._proc is not None and self._proc.poll() is None + + def start(self) -> None: + with self._lock: + if self.running: + return + self._lines = queue.Queue() + env = {**os.environ, "CI": ""} + self._proc = subprocess.Popen( + [self.luajit, str(self.root / "engine" / "worker.lua")], + cwd=self.root / "src", + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + env=env, + ) + threading.Thread(target=self._pump, args=(self._proc.stdout, self._lines.put), daemon=True).start() + threading.Thread(target=self._pump, args=(self._proc.stderr, self._log_stderr), daemon=True).start() + msg = self._read(self.start_timeout, "starting") + if msg.get("event") != "ready": + err = msg.get("error", {}).get("message", msg) + self.stop() + raise EngineError(f"Worker failed to start: {err}{self._stderr_tail()}", "startup") + self.methods = msg.get("methods", []) + log.info("PoB worker ready (%d methods)", len(self.methods)) + + def stop(self) -> None: + with self._lock: + proc, self._proc = self._proc, None + if not proc: + return + try: + if proc.poll() is None and proc.stdin: + proc.stdin.write(json.dumps({"id": 0, "method": "worker.shutdown"}) + "\n") + proc.stdin.flush() + proc.wait(timeout=5) + except (OSError, subprocess.TimeoutExpired): + proc.kill() + + def restart(self) -> None: + self.stop() + self.start() + + def __enter__(self) -> Engine: + self.start() + return self + + def __exit__(self, *exc: object) -> None: + self.stop() + + # -- calls ----------------------------------------------------------------------------------- + + def call(self, method: str, params: dict[str, Any] | None = None, timeout: float | None = None) -> Any: + with self._lock: + if not self.running: + self.start() + assert self._proc and self._proc.stdin + req_id = next(self._ids) + payload = {"id": req_id, "method": method, "params": params or {}} + try: + self._proc.stdin.write(json.dumps(payload) + "\n") + self._proc.stdin.flush() + except OSError as exc: + self.stop() + raise EngineError(f"Worker pipe closed: {exc}{self._stderr_tail()}", "crashed") from exc + while True: + msg = self._read(timeout or self.call_timeout, method) + if msg.get("id") == req_id: + break + if "error" in msg: + err = msg["error"] or {} + raise EngineError( + err.get("message", "Unknown error"), err.get("code", "internal"), err.get("traceback") + ) + return msg.get("result") + + # -- internals ------------------------------------------------------------------------------- + + def _read(self, timeout: float, context: str) -> dict[str, Any]: + try: + line = self._lines.get(timeout=timeout) + except queue.Empty: + self.stop() + raise EngineError(f"Worker timed out ({context}) after {timeout:.0f}s", "timeout") from None + if line is None: + self._proc = None + raise EngineError(f"Worker exited unexpectedly ({context}).{self._stderr_tail()}", "crashed") + try: + return json.loads(line) + except json.JSONDecodeError: + log.warning("Non-JSON worker output: %s", line.rstrip()) + return self._read(timeout, context) + + @staticmethod + def _pump(stream: Any, sink: Any) -> None: + for line in stream: + sink(line) + sink(None) + + def _log_stderr(self, line: str | None) -> None: + if line is not None: + self._stderr.append(line.rstrip()) + log.debug("lua: %s", line.rstrip()) + + def _stderr_tail(self) -> str: + tail = "\n".join(list(self._stderr)[-15:]) + return f"\n--- worker stderr ---\n{tail}" if tail else "" diff --git a/python/src/pob2/market.py b/python/src/pob2/market.py new file mode 100644 index 0000000000..9f0c62edfe --- /dev/null +++ b/python/src/pob2/market.py @@ -0,0 +1,171 @@ +"""poe.ninja (PoE2) prices and characters, and pathofexile.com trade-site search links. + +poe.ninja endpoints used (all public, JSON): + /poe2/api/data/index-state leagues and build snapshot versions + /poe2/api/economy/stash/current/item/overview unique prices (primaryValue is in divine orbs) + /poe2/api/builds/{version}/character a ladder character's Path of Building export + /poe2/api/profile/characters/{acc}/{league}/{name}/model/{n} a profile character's export (n: cache-buster) +""" + +from __future__ import annotations + +import json +import re +import threading +import time +from typing import Any +from urllib.parse import quote, unquote + +import httpx + +NINJA = "https://poe.ninja" +TRADE = "https://www.pathofexile.com/trade2/search/poe2" +HEADERS = {"User-Agent": "PoB2-Enhanced (+https://github.com/PathOfBuildingCommunity/PathOfBuilding-PoE2)"} +UNIQUE_TYPES = ("UniqueWeapons", "UniqueArmours", "UniqueAccessories", "UniqueJewels", "UniqueFlasks", "UniqueCharms") +# Ladder page: /poe2/builds//character// +# Profile page: /poe2/profile///character/ +CHARACTER_URL = re.compile(r"^https?://poe\.ninja/poe2/builds/([\w-]+)/character/([^/?#]+)/([^/?#]+)") +PROFILE_URL = re.compile(r"^https?://poe\.ninja/poe2/profile/([^/?#]+)/([\w-]+)/character/([^/?#]+)") + +_cache: dict[str, tuple[float, Any]] = {} +_lock = threading.Lock() + + +def _get_json(url: str, ttl: float, params: dict[str, str] | None = None, timeout: float = 20.0) -> Any: + key = url + json.dumps(params or {}, sort_keys=True) + with _lock: + hit = _cache.get(key) + if hit and time.monotonic() - hit[0] < ttl: + return hit[1] + resp = httpx.get(url, params=params, headers=HEADERS, timeout=timeout, follow_redirects=True) + resp.raise_for_status() + data = resp.json() + with _lock: + _cache[key] = (time.monotonic(), data) + return data + + +def index_state() -> dict[str, Any]: + return _get_json(f"{NINJA}/poe2/api/data/index-state", ttl=600) + + +def leagues() -> list[dict[str, Any]]: + """Trade/economy leagues, current challenge leagues first.""" + return [ + {"name": lg["name"], "url": lg["url"], "hardcore": lg.get("hardcore", False)} + for lg in index_state().get("economyLeagues", []) + ] + + +def default_league() -> str | None: + for lg in leagues(): + if not lg["hardcore"]: + return lg["name"] + return None + + +def league_from_slug(slug: str) -> str | None: + state = index_state() + for lg in state.get("economyLeagues", []) + state.get("buildLeagues", []) + state.get("snapshotVersions", []): + if lg.get("url") == slug: + return lg.get("name") + return None + + +def unique_prices(league: str) -> dict[str, Any]: + """{"rates": {"exalted": n, "chaos": n} per divine, "items": {name: {"divine", "listings", "baseType"}}}.""" + items: dict[str, dict[str, Any]] = {} + rates: dict[str, float] = {} + for kind in UNIQUE_TYPES: + try: + data = _get_json( + f"{NINJA}/poe2/api/economy/stash/current/item/overview", + ttl=1800, + params={"league": league, "type": kind}, + ) + except httpx.HTTPError: + continue + rates = (data.get("core") or {}).get("rates") or rates + for line in data.get("lines") or []: + name = line.get("name") + if not name or line.get("primaryValue") is None: + continue + entry = { + "divine": line["primaryValue"], + "listings": line.get("listingCount"), + "baseType": line.get("baseType"), + } + # Several variants can share a name; keep the cheapest listing. + if name not in items or entry["divine"] < items[name]["divine"]: + items[name] = entry + return {"rates": rates, "items": items} + + +def format_price(divine: float, rates: dict[str, float]) -> str: + if divine >= 1: + return f"{divine:,.1f} div" if divine < 100 else f"{divine:,.0f} div" + exalted = divine * (rates.get("exalted") or 0) + return f"{exalted:,.0f} ex" if exalted else f"{divine:.2f} div" + + +def trade_url(league: str, query: dict[str, Any]) -> str: + return f"{TRADE}/{quote(league)}?q={quote(json.dumps(query, separators=(',', ':')))}" + + +def unique_query(name: str, base: str | None = None) -> dict[str, Any]: + query: dict[str, Any] = {"status": {"option": "online"}, "name": name} + if base: + query["type"] = base + return {"query": query, "sort": {"price": "asc"}} + + +def parse_character_url(link: str) -> tuple[str, str, str, str] | None: + """(kind, league slug, account, name) for a poe.ninja ladder or profile character link.""" + link = link.strip() + if m := CHARACTER_URL.match(link): + return "ladder", m.group(1), unquote(m.group(2)), unquote(m.group(3)) + if m := PROFILE_URL.match(link): + return "profile", m.group(2), unquote(m.group(1)), unquote(m.group(3)) + return None + + +def is_character_url(link: str) -> bool: + return parse_character_url(link) is not None + + +def character_league(link: str) -> str | None: + parsed = parse_character_url(link) + return league_from_slug(parsed[1]) if parsed else None + + +def character_code(link: str) -> tuple[str, str | None]: + """poe.ninja character page (ladder or profile) -> (PoB share code, league name).""" + parsed = parse_character_url(link) + if not parsed: + raise ValueError("Not a poe.ninja PoE2 character link") + kind, slug, account, name = parsed + if kind == "profile": + # The trailing number only busts poe.ninja's cache (the site uses the character level); any value works. + path = f"/poe2/api/profile/characters/{quote(account)}/{quote(slug)}/{quote(name)}" + data = _get_json(f"{NINJA}{path}/model/{int(time.time() // 60)}", ttl=60) + model = data.get("charModel") or {} + if data.get("type") != "found" or not model.get("pathOfBuildingExport"): + raise ValueError("poe.ninja has no public profile data for this character (is the profile public?)") + return model["pathOfBuildingExport"], league_from_slug(slug) + snapshot = next((s for s in index_state().get("snapshotVersions", []) if s.get("url") == slug), None) + if not snapshot: + raise ValueError(f"poe.ninja has no build snapshot for league '{slug}'") + data = _get_json( + f"{NINJA}/poe2/api/builds/{snapshot['version']}/character", + ttl=60, + params={ + "account": account, + "name": name, + "overview": snapshot["snapshotName"], + "timeMachine": "", + }, + ) + code = data.get("pathOfBuildingExport") + if not code: + raise ValueError("poe.ninja returned no Path of Building export for this character") + return code, snapshot.get("name") diff --git a/python/src/pob2/mcp_server.py b/python/src/pob2/mcp_server.py new file mode 100644 index 0000000000..79f1916a2b --- /dev/null +++ b/python/src/pob2/mcp_server.py @@ -0,0 +1,543 @@ +"""MCP server exposing a live Path of Building 2 build to AI agents. + +Run: uv run --directory python pob2-mcp (stdio transport) + uv run --directory python pob2-mcp --http (streamable HTTP on 127.0.0.1:8765/mcp) +""" + +from __future__ import annotations + +import argparse +import functools +import logging +import sys +from typing import Annotated, Any + +import httpx +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.types import ToolAnnotations +from pydantic import Field + +from pob2.codec import CodecError +from pob2.engine import EngineError +from pob2.service import PoBService + +log = logging.getLogger("pob2.mcp") + +INSTRUCTIONS = """\ +Path of Building 2 (Path of Exile 2 build planner) running headless. One build is active at a time. +Typical flow: load_build (share code / pobb.in link / XML) or new_build -> get_build_overview -> +inspect with get_stats, list_skills, list_items, get_passive_tree -> evaluate changes with +compare_item / compare_passives BEFORE applying them with equip_item / allocate_passives -> +export_build to hand the result back as a share code. +To optimise toward a guide, load its PoE2 BuildPlanner .build file with load_reference_build, then use +compare_to_reference (passive/skill/gear gaps) and reference_impact (ranked next steps by gain per point). +get_advice says where the next point goes, which point to move and which attribute nodes to switch. +BEFORE recommending that the user buy, equip or swap any item, call check_item and report its verdict, warnings +(Spirit, totems, attribute requirements) and every damage skill. Never compare raw TotalDPS for totem skills: PoB +reports totem DPS per totem; check_item/get_budget count the totems the Spirit budget can actually keep up. +gear_advice, shopping_advice and gem_advice cover items, jewels, uniques to buy and gems; refresh_build re-imports. +Stat values are PoB's calculated numbers for the current configuration (see list_config); +enemy conditions and buffs in the config strongly affect DPS. +""" + +mcp = MCPServer("pob2-enhanced", title="Path of Building 2", instructions=INSTRUCTIONS, version="0.1.0") +_service: PoBService | None = None + +READ = ToolAnnotations(readOnlyHint=True, idempotentHint=True, openWorldHint=False) +WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False) +REPLACE = ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=False) + +NodeIds = Annotated[list[int], Field(description="Passive node ids (from search_passives or get_passive_tree)")] + + +def tool(annotations: ToolAnnotations) -> Any: + """Register a tool, surfacing engine/codec errors to the agent as readable tool errors.""" + + def decorator(fn: Any) -> Any: + @functools.wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return fn(*args, **kwargs) + except (EngineError, CodecError, httpx.HTTPError, ValueError, OSError) as exc: + raise ToolError(str(exc)) from exc + + return mcp.tool(annotations=annotations)(wrapper) + + return decorator + + +def svc() -> PoBService: + global _service + if _service is None: + _service = PoBService() + return _service + + +# -- build ----------------------------------------------------------------------------------------- + + +@tool(REPLACE) +def load_build( + source: Annotated[str, Field(description="PoB share code, pobb.in/pastebin/poe.ninja link, or build XML")], +) -> dict[str, Any]: + """Load a build, replacing the active one. Returns basic build info.""" + return svc().load(source) + + +@tool(REPLACE) +def new_build( + class_name: Annotated[str | None, Field(description="e.g. Witch, Ranger, Warrior, Sorceress")] = None, + ascendancy: Annotated[str | None, Field(description="e.g. Infernalist, Deadeye")] = None, + level: Annotated[int | None, Field(ge=1, le=100)] = None, +) -> dict[str, Any]: + """Start an empty build, replacing the active one.""" + return svc().new_build(class_name, ascendancy, level) + + +@tool(READ) +def get_build_overview() -> dict[str, Any]: + """Class, level, main skill, sidebar stats, skill groups, equipped items and non-default config.""" + return svc().overview() + + +@tool(READ) +def export_build(include_xml: bool = False) -> dict[str, Any]: + """Export the active build as a PoB share code (importable in Path of Building / pobb.in).""" + result = svc().export() + return result if include_xml else {"code": result["code"]} + + +@tool(WRITE) +def set_character( + level: Annotated[int | None, Field(ge=1, le=100)] = None, + class_name: str | None = None, + ascendancy: str | None = None, +) -> dict[str, Any]: + """Change character level, class or ascendancy. Changing class resets the passive tree.""" + return svc().set_character(level, class_name, ascendancy) + + +@tool(READ) +def list_classes() -> list[dict[str, Any]]: + """All classes and their ascendancies.""" + return svc().call("data.classes") + + +# -- stats ----------------------------------------------------------------------------------------- + + +@tool(READ) +def get_stats( + keys: Annotated[ + list[str] | None, + Field(description="Specific output keys, e.g. ['TotalDPS','Life','FireResist']. Omit for all scalar stats."), + ] = None, +) -> dict[str, Any]: + """Raw calculated output values for the current build and main skill.""" + return svc().call("stats.get", keys=keys) + + +@tool(READ) +def get_stat_summary() -> dict[str, Any]: + """The formatted sidebar stats (DPS, defences, resistances, costs) as shown in PoB.""" + return svc().call("stats.summary") + + +# -- skills ---------------------------------------------------------------------------------------- + + +@tool(READ) +def list_skills() -> list[dict[str, Any]]: + """Skill groups (socket groups) with gems, which group is the main skill, and active skills.""" + return svc().call("skills.list") + + +@tool(WRITE) +def add_skill_group( + gems: Annotated[ + list[str], + Field( + description="Gem names, active skill first then supports. " + "Optionally 'Name level/quality', e.g. 'Fireball 20/0'" + ), + ], + label: str | None = None, + make_main: bool = True, +) -> dict[str, Any]: + """Add a skill group. Unrecognised gem names are reported in 'warnings' with suggestions.""" + return svc().call("skills.add", gems=gems, label=label, makeMain=make_main) + + +@tool(WRITE) +def set_main_skill( + group: Annotated[int, Field(description="Skill group index from list_skills")], + skill: Annotated[int | None, Field(description="Active skill index within the group")] = None, +) -> dict[str, Any]: + """Choose which skill group (and active skill) the DPS calculations are for.""" + return svc().call("skills.set_main", group=group, skill=skill) + + +@tool(WRITE) +def set_skill_group_enabled(group: int, enabled: bool) -> dict[str, Any]: + """Enable or disable a skill group (e.g. toggle an aura or buff).""" + return svc().call("skills.set_enabled", group=group, enabled=enabled) + + +@tool(ToolAnnotations(destructiveHint=True, openWorldHint=False)) +def remove_skill_group(group: int) -> list[dict[str, Any]]: + """Remove a skill group. Returns the remaining groups.""" + return svc().call("skills.remove", group=group) + + +@tool(READ) +def search_gems(query: str = "", support: bool | None = None, limit: int = 30) -> list[dict[str, Any]]: + """Search skill and support gems by name or tag (e.g. 'fire', 'minion', 'projectile').""" + return svc().call("data.gems", query=query, support=support, limit=limit) + + +# -- items ----------------------------------------------------------------------------------------- + + +@tool(READ) +def list_items(include_mods: bool = True) -> list[dict[str, Any]]: + """Equipped items by slot, including mod lines (with 'supported' = whether PoB models the mod).""" + return svc().call("items.list", full=include_mods) + + +@tool(READ) +def list_item_slots() -> list[str]: + """Valid slot names for equip_item / compare_item.""" + return svc().call("items.slots") + + +@tool(READ) +def compare_item( + item_text: Annotated[str, Field(description="Item text as copied from the game (Ctrl+C) or PoB")], + slot: Annotated[str | None, Field(description="Slot to compare against; defaults to the item's slot")] = None, +) -> dict[str, Any]: + """What-if: stat changes if this item replaced the one in the slot. Does not modify the build.""" + return svc().call("stats.compare_item", text=item_text, slot=slot) + + +@tool(WRITE) +def equip_item(item_text: str, slot: str | None = None) -> dict[str, Any]: + """Equip an item from its text. Defaults to the item's natural slot.""" + return svc().call("items.equip", text=item_text, slot=slot) + + +@tool(WRITE) +def unequip_item(slot: str) -> bool: + """Empty an item slot.""" + return svc().call("items.unequip", slot=slot) + + +@tool(READ) +def parse_item(item_text: str) -> dict[str, Any]: + """Parse item text and report which mods PoB supports, without equipping it.""" + return svc().call("items.parse", text=item_text) + + +@tool(READ) +def search_uniques(query: str = "", item_type: str | None = None, limit: int = 30) -> list[dict[str, Any]]: + """Search the unique item database by name or base (item_type e.g. 'Helmet', 'Wand').""" + return svc().call("data.uniques", query=query, type=item_type, limit=limit) + + +@tool(READ) +def get_unique(name: str) -> dict[str, Any]: + """Full item text of a unique, usable with compare_item / equip_item.""" + return svc().call("data.unique", name=name) + + +@tool(READ) +def search_item_bases(query: str = "", item_type: str | None = None, limit: int = 30) -> list[dict[str, Any]]: + """Search item base types (for writing rare item text).""" + return svc().call("data.bases", query=query, type=item_type, limit=limit) + + +# -- passive tree ---------------------------------------------------------------------------------- + + +@tool(READ) +def get_passive_tree() -> dict[str, Any]: + """Allocated passive nodes with their stats, and points used.""" + return svc().call("tree.get") + + +@tool(READ) +def search_passives( + query: Annotated[str, Field(description="Text in node name or stats, e.g. 'spell damage', 'Raw Destruction'")], + include_minor: Annotated[bool, Field(description="Include small (non-notable) nodes")] = False, + limit: int = 25, +) -> list[dict[str, Any]]: + """Find passive nodes. pathLength = points needed to reach an unallocated node.""" + return svc().call("tree.search", query=query, includeMinor=include_minor, limit=limit) + + +@tool(READ) +def compare_passives(add: NodeIds | None = None, remove: NodeIds | None = None) -> dict[str, Any]: + """What-if: stat changes from allocating (including path) and/or removing nodes. No changes made.""" + return svc().call("stats.compare_nodes", add=add, remove=remove) + + +@tool(WRITE) +def allocate_passives(ids: NodeIds) -> dict[str, Any]: + """Allocate nodes, pathing through the shortest route. Unreachable ids are listed in 'failed'.""" + return svc().call("tree.allocate", ids=ids) + + +@tool(WRITE) +def deallocate_passives(ids: NodeIds) -> dict[str, Any]: + """Deallocate nodes and everything that depends on them.""" + return svc().call("tree.deallocate", ids=ids) + + +@tool(WRITE) +def import_tree_url(url: str) -> dict[str, Any]: + """Replace the passive tree from a pathofexile2.com/game/passive-skill-tree/ link (or its code).""" + return svc().call("tree.import_url", url=url) + + +@tool(READ) +def export_tree_url() -> dict[str, Any]: + """Link to the current passive tree on the official PoE2 planner (pathofexile2.com).""" + return svc().call("tree.export_url") + + +# -- configuration --------------------------------------------------------------------------------- + + +@tool(READ) +def list_config( + query: Annotated[str | None, Field(description="Filter by label or variable, e.g. 'shock', 'boss'")] = None, + include_hidden: Annotated[bool, Field(description="Include options not relevant to this build")] = False, +) -> list[dict[str, Any]]: + """Configuration options (enemy state, buffs, conditions, charges) with current values.""" + return svc().call("config.list", query=query, all=include_hidden) + + +@tool(WRITE) +def set_config( + values: Annotated[dict[str, Any], Field(description="{var: value} using 'var' names from list_config")], +) -> dict[str, Any]: + """Change configuration options and recalculate.""" + return svc().call("config.set", values=values) + + +@tool(WRITE) +def set_custom_mods( + text: Annotated[str, Field(description="One modifier per line, e.g. '+20% to Fire Resistance'")], +) -> dict[str, Any]: + """Replace the build's custom modifiers (Config tab) to model effects PoB cannot parse elsewhere.""" + return svc().call("config.custom_mods", text=text) + + +# -- resources & prompts --------------------------------------------------------------------------- + + +@mcp.resource("pob://build/code", mime_type="text/plain") +def build_code() -> str: + """Share code of the active build.""" + return svc().export()["code"] + + +@mcp.resource("pob://build/xml", mime_type="application/xml") +def build_xml() -> str: + """Full XML of the active build.""" + return svc().export()["xml"] + + +# -- reference build ------------------------------------------------------------------------------- + +Level = Annotated[ + int | None, Field(None, ge=1, le=100, description="Character level for staged guides (default: build level)") +] + + +@tool(WRITE) +def load_reference_build( + content: Annotated[str | None, Field(description="Contents of a PoE2 BuildPlanner .build file (JSON)")] = None, + path: Annotated[str | None, Field(description="Path to a .build file readable by the server")] = None, +) -> dict[str, Any] | None: + """Load a guide's .build file as the reference the live build is compared against.""" + if content is None and path is None: + raise ToolError("Pass content or path") + if content is None: + with open(path, encoding="utf-8-sig") as f: # noqa: PTH123 + content = f.read() + return svc().set_reference(content) + + +@tool(READ) +def compare_to_reference(level: Level = None) -> dict[str, Any]: + """Gaps vs the reference: missing/extra passives, ascendancy, skill gems and supports, gear and guide notes.""" + return svc().reference_compare(level) + + +@tool(READ) +def reference_impact(level: Level = None, limit: int = 20) -> dict[str, Any]: + """Stats on the full reference tree, guide notables ranked by DPS%+EHP% per point (with paths), and + what each extra notable you have is worth. No changes made.""" + return svc().reference_impact(level, limit) + + +@tool(REPLACE) +def apply_reference_tree(level: Level = None) -> dict[str, Any]: + """Replace the passive tree with the reference tree (keeps class; attribute choices preserved).""" + return svc().apply_reference_tree(level) + + +@tool(READ) +def get_advice( + metric: Annotated[str, Field(description="balanced, dps or ehp")] = "balanced", level: Level = None +) -> dict[str, Any]: + """Where the next passive point should go (with path), which allocated point to move, and recommended vs + current attribute node choices with reasons. Follows the reference guide when one is loaded. No changes made.""" + return svc().advice(metric, level) + + +AttributeChanges = Annotated[ + list[dict[str, Any]], + Field(description='[{"id": , "attribute": "Strength" | "Dexterity" | "Intelligence"}]'), +] + + +@tool(WRITE) +def set_attribute_nodes(changes: AttributeChanges) -> dict[str, Any]: + """Choose the +5 attribute on attribute nodes.""" + return svc().set_attributes(changes) + + +Metric = Annotated[str, Field(description="balanced, dps or ehp")] + + +@tool(REPLACE) +def refresh_build() -> dict[str, Any]: + """Re-import the build from the link it was loaded from (e.g. a poe.ninja character page), keeping config.""" + return svc().refresh() + + +@tool(READ) +def gear_advice( + slot: Annotated[str | None, Field(description="One slot (e.g. Gloves, Ring 1, Jewel 26196); omit for all")] = None, + metric: Metric = "balanced", + league: Annotated[str | None, Field(description="Trade league for search links")] = None, +) -> Any: + """Per slot: what the item and each mod is worth (dead mods flagged), what to look for on a replacement, + upgrade headroom and a weighted trade-site search URL. All slots are sorted by headroom (best upgrade first).""" + return svc().gear_slot(slot, metric, league) if slot else svc().gear_overview(metric, league) + + +@tool(READ) +def shopping_advice( + metric: Metric = "balanced", + league: Annotated[str | None, Field(description="Trade league (default: the build's or current league)")] = None, + max_price: Annotated[float | None, Field(description="Budget in divine orbs")] = None, +) -> dict[str, Any]: + """Uniques that would help this build most per slot, measured in PoB, with poe.ninja prices and trade links.""" + return svc().gear_uniques(metric, league, max_price) + + +@tool(READ) +def socket_advice( + metric: Metric = "balanced", + slot: Annotated[str | None, Field(description="One slot (e.g. Body Armour); omit for all socketed items")] = None, + optimize: Annotated[bool, Field(description="Also build a combined swap plan (slower, ~15s)")] = False, +) -> dict[str, Any]: + """Runes, soul cores and idols: each socketed augment's worth, the best legal replacements per socket (corruption, + level, per-item and character-wide limits such as Ancient Augment checked) and, with optimize, a combined plan of + swaps re-measured after each one. Lines PoB doesn't model are flagged 'unmodelled'.""" + res = svc().socket_advice(metric, slot) + if optimize: + res = {**res, "plan": svc().socket_plan(metric)} + return res + + +@tool(READ) +def gem_advice(metric: Metric = "balanced") -> dict[str, Any]: + """Skill gem advice: worth of each support, best replacement for the weakest support per skill (regular and + lineage), gem level/quality upgrades ranked by gain, and setup problems (disabled, duplicate, harmful).""" + return svc().gems_advice(metric) + + +@tool(WRITE) +def set_gem( + group: Annotated[int, Field(description="Socket group number (from list_skills)")], + index: Annotated[int, Field(description="Gem position in the group, 1-based")], + name: Annotated[str | None, Field(description="Replace with this gem")] = None, + level: int | None = None, + quality: int | None = None, + enabled: bool | None = None, +) -> dict[str, Any]: + """Change one gem: swap it, or set its level, quality or enabled state.""" + return svc().set_gem(group, index, name=name, level=level, quality=quality, enabled=enabled) + + +@tool(READ) +def check_item( + text: Annotated[str, Field(description="Item text copied from the game (Ctrl+C) or trade site")], + slot: Annotated[str | None, Field(description="Slot to test (default: wherever it does best)")] = None, +) -> dict[str, Any]: + """Check before you buy: upgrade/sidegrade/downgrade verdict with reasons, each damage skill's DPS counting + totems, Spirit and totems before/after, defences and attribute requirements. Use before any purchase advice.""" + return svc().check_item(text, slot) + + +@tool(READ) +def get_budget() -> dict[str, Any]: + """Spirit total, what reserves it, free Spirit, totems affordable vs the totem cap, main-skill DPS across totems.""" + return svc().budget() + + +@mcp.prompt() +def optimize_to_reference(code: str) -> str: + """Plan how to move a character toward a loaded reference guide.""" + return ( + f"Load this build with load_build:\n\n{code}\n\n" + "A reference guide is loaded (load_reference_build). Call compare_to_reference and reference_impact. " + "Report: ascendancy/skill/support gaps, gear gaps with the guide's stat priorities, and a passive plan " + "ordered by gain per point (use the paths given), plus which extra nodes are cheapest to refund. " + "Quantify with the measured deltas. Ask before applying anything." + ) + + +@mcp.prompt() +def analyze_build(code: str) -> str: + """Review a build: strengths, weaknesses and prioritised upgrades.""" + return ( + f"Load this Path of Building 2 build with load_build, then call get_build_overview.\n\n{code}\n\n" + "Review offence and defence (resistances, max hit taken, EHP, recovery). Identify the top 5 " + "weaknesses, and for each propose a concrete change. Validate every proposal with compare_item " + "or compare_passives and report the measured deltas. Do not apply changes." + ) + + +@mcp.prompt() +def optimize_passives(goal: str = "more DPS without losing life", points: int = 5) -> str: + """Find the best passive nodes for a goal within a point budget.""" + return ( + f"Goal: {goal}. Budget: {points} passive points.\n" + "Use search_passives to find candidate notables, compare_passives to measure each (note pathLength), " + "rank by gain per point, and present a plan. Ask before calling allocate_passives." + ) + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(description="PoB2-Enhanced MCP server") + parser.add_argument("--http", action="store_true", help="Serve streamable HTTP instead of stdio") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--eager", action="store_true", help="Start the PoB engine immediately") + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(levelname)s %(name)s: %(message)s") + if args.eager: + svc().engine.start() + if args.http: + mcp.run(transport="streamable-http", host=args.host, port=args.port) + else: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/python/src/pob2/service.py b/python/src/pob2/service.py new file mode 100644 index 0000000000..829fb9089f --- /dev/null +++ b/python/src/pob2/service.py @@ -0,0 +1,370 @@ +"""High-level, typed operations on a PoB build. Shared by the MCP server, REST API and CLI.""" + +from __future__ import annotations + +import json +import threading +import time +from typing import Any + +from pob2 import codec, market +from pob2.engine import Engine, EngineError + +# Worker methods that change the build. Used to bump `revision` so UIs can refresh. +MUTATING = { + "build.new", + "build.load_xml", + "build.set_level", + "build.set_class", + "skills.add", + "skills.remove", + "skills.set_main", + "skills.set_enabled", + "items.equip", + "items.unequip", + "tree.allocate", + "tree.deallocate", + "tree.reset", + "tree.import_url", + "config.set", + "reference.apply_tree", + "tree.set_attributes", + "skills.set_gem", +} + +# Largest accepted reference .build file (they are typically 20-100 KB). +MAX_REFERENCE_BYTES = 2_000_000 + + +class PoBService: + """Wraps one Engine (one active build) with convenience operations.""" + + def __init__(self, engine: Engine | None = None): + self.engine = engine or Engine() + self.revision = 0 + self._layout_cache: dict[str, Any] = {} + self._lock = threading.RLock() + # Reference build (PoE2 BuildPlanner .build JSON) the live build is compared against. + self.reference: dict[str, Any] | None = None + self._advice_cache: tuple[tuple[Any, ...], dict[str, Any]] | None = None + # Where the build came from (a link), so it can be re-imported with one click. + self.source: str | None = None + self.source_league: str | None = None + self.source_loaded_at: float | None = None + self._memo: dict[tuple[Any, ...], Any] = {} + + def call(self, method: str, **params: Any) -> Any: + result = self.engine.call(method, {k: v for k, v in params.items() if v is not None}) + if method in MUTATING or (method == "config.custom_mods" and params.get("text") is not None): + self.revision += 1 + return result + + # -- build ----------------------------------------------------------------------------------- + + def new_build( + self, + class_name: str | None = None, + ascendancy: str | None = None, + level: int | None = None, + name: str | None = None, + ) -> dict[str, Any]: + info = self.call("build.new", name=name) + if class_name or ascendancy: + info = self.call("build.set_class", className=class_name, ascendClassName=ascendancy) + if level: + info = self.call("build.set_level", level=level) + return info + + def set_character( + self, level: int | None = None, class_name: str | None = None, ascendancy: str | None = None + ) -> dict[str, Any]: + info = None + if class_name or ascendancy: + info = self.call("build.set_class", className=class_name, ascendClassName=ascendancy) + if level: + info = self.call("build.set_level", level=level) + return info or self.info() + + def load(self, source: str, name: str | None = None) -> dict[str, Any]: + """Load from XML, a share code, or a pobb.in / pastebin / poe.ninja link (paste or character page).""" + xml = codec.resolve(source) + info = self.call("build.load_xml", xml=xml, name=name) + link = source.strip() + if link.startswith(("http://", "https://")): + self.source = link + try: + self.source_league = market.character_league(link) + except Exception: # noqa: BLE001 - league lookup is a convenience + self.source_league = None + else: + self.source = self.source_league = None + self.source_loaded_at = time.time() + return info + + def source_info(self) -> dict[str, Any]: + return { + "source": self.source, + "character": bool(self.source and market.is_character_url(self.source)), + "league": self.source_league, + "loadedAt": self.source_loaded_at, + } + + def refresh(self) -> dict[str, Any]: + """Re-import the build from its source link, keeping the Config tab settings.""" + if not self.source: + raise ValueError("This build wasn't imported from a link, so there is nothing to refresh from") + keys = ["TotalDPS", "CombinedDPS", "Life", "EnergyShield", "TotalEHP"] + before = self.call("stats.get", keys=keys) + config = { + c["var"]: c["value"] + for c in self.call("config.list", all=True) + if c.get("value") is not None and c.get("value") != c.get("default") + } + info = self.load(self.source) + if config: + try: + self.call("config.set", values=config) + except EngineError: + pass # options that no longer apply to the refreshed build + after = self.call("stats.get", keys=keys) + return {"info": info, "before": before, "after": after, "source": self.source_info()} + + def export(self) -> dict[str, str]: + xml = self.call("build.export_xml")["xml"] + return {"code": codec.encode_code(xml), "xml": xml} + + def info(self) -> dict[str, Any]: + return self.call("build.info") + + def overview(self) -> dict[str, Any]: + """Everything needed to understand the build in one call.""" + items = self.call("items.list", full=False) + config = self.call("config.list") + return { + "info": self.info(), + "stats": self.call("stats.summary"), + "skills": self.call("skills.list"), + "items": [ + {"slot": i["slot"], "name": i["item"]["name"], "rarity": i["item"]["rarity"]} + for i in items + if i.get("item") + ], + "config": { + c["var"]: c["value"] + for c in config + if c.get("value") is not None and c.get("value") != c.get("default") + }, + } + + # -- reference build --------------------------------------------------------------------------- + + def set_reference(self, content: str | dict[str, Any]) -> dict[str, Any]: + """Store a PoE2 BuildPlanner (.build) guide to compare the live build against.""" + if isinstance(content, str): + if len(content.encode("utf-8")) > MAX_REFERENCE_BYTES: + raise ValueError("Reference file is too large") + try: + ref = json.loads(content.lstrip("")) + except json.JSONDecodeError as e: + raise ValueError(f"Not a .build file (invalid JSON: {e.msg})") from e + else: + ref = content + sections = ("passives", "skills", "inventory_slots") + if not isinstance(ref, dict) or not any(isinstance(ref.get(k), list) for k in sections): + raise ValueError("Not a PoE2 BuildPlanner .build file (no passives, skills or inventory_slots)") + self.reference = ref + self.revision += 1 + return self.reference_summary() + + def clear_reference(self) -> None: + self.reference = None + self.revision += 1 + + def _require_reference(self) -> dict[str, Any]: + if self.reference is None: + raise ValueError("No reference build loaded") + return self.reference + + def reference_summary(self) -> dict[str, Any] | None: + ref = self.reference + if ref is None: + return None + return { + "name": ref.get("name"), + "author": ref.get("author"), + "link": ref.get("link"), + "ascendancy": ref.get("ascendancy"), + "passives": len(ref.get("passives") or []), + "skills": len(ref.get("skills") or []), + "items": len(ref.get("inventory_slots") or []), + } + + def reference_compare(self, level: int | None = None) -> dict[str, Any]: + """Passive, skill and gear differences between the live build and the reference at a level.""" + return self.call("reference.compare", reference=self._require_reference(), level=level) + + def reference_impact(self, level: int | None = None, limit: int | None = None) -> dict[str, Any]: + """Stat impact of the reference tree, ranked next steps and the value of extra nodes.""" + return self.call("reference.impact", reference=self._require_reference(), level=level, limit=limit) + + def apply_reference_tree(self, level: int | None = None) -> dict[str, Any]: + return self.call("reference.apply_tree", reference=self._require_reference(), level=level) + + # -- advisor ------------------------------------------------------------------------------------- + + def advice(self, metric: str = "balanced", level: int | None = None, limit: int | None = None) -> dict[str, Any]: + """Next passive point, which point to move, and attribute node choices (follows the reference if loaded).""" + key = (self.revision, id(self.reference), metric, level, limit) + with self._lock: + if self._advice_cache and self._advice_cache[0] == key: + return self._advice_cache[1] + result = self.call("advisor.plan", reference=self.reference, metric=metric, level=level, limit=limit) + with self._lock: + self._advice_cache = (key, result) + return result + + def set_attributes(self, changes: list[dict[str, Any]]) -> dict[str, Any]: + """Choose +5 Strength/Dexterity/Intelligence on attribute nodes: [{"id": 123, "attribute": "Strength"}].""" + return self.call("tree.set_attributes", changes=changes) + + # -- spirit / totem budget ------------------------------------------------------------------------- + + def budget(self) -> dict[str, Any]: + """Spirit total/reserved/free, totems affordable vs cap, and main-skill DPS across all totems.""" + return self.call("budget.get") + + def check_item(self, text: str, slot: str | None = None) -> dict[str, Any]: + """Check before you buy: verdict, every damage skill (totems counted), Spirit/totems, defences, requirements.""" + return self.call("budget.check_item", text=text, slot=slot) + + # -- gear, shopping and gems --------------------------------------------------------------------- + + def _memoised(self, key: tuple[Any, ...], fn: Any) -> Any: + """Cache an engine result for the current build revision.""" + full = (self.revision, *key) + with self._lock: + if full in self._memo: + return self._memo[full] + result = fn() + with self._lock: + self._memo = {k: v for k, v in self._memo.items() if k[0] == self.revision} + self._memo[full] = result + return result + + def league(self, league: str | None = None) -> str | None: + if league: + return league + if self.source_league: + return self.source_league + try: + return market.default_league() + except Exception: # noqa: BLE001 - poe.ninja unreachable; links just won't have a league + return None + + def gear_slots(self) -> list[dict[str, Any]]: + return self._memoised(("gear.slots",), lambda: self.call("gear.slots")) + + def gear_slot(self, slot: str, metric: str = "balanced", league: str | None = None) -> dict[str, Any]: + """What the item in a slot and each of its mods is worth, what to look for, and a weighted trade search.""" + cached = self._memoised(("gear.slot", slot, metric), lambda: self.call("gear.slot", slot=slot, metric=metric)) + result = dict(cached) + league = self.league(league) + if league and result.get("tradeQuery"): + result["tradeUrl"] = market.trade_url(league, result["tradeQuery"]) + result["league"] = league + return result + + def gear_overview(self, metric: str = "balanced", league: str | None = None) -> list[dict[str, Any]]: + """Every slot's analysis, best upgrade opportunity first.""" + rows = [self.gear_slot(s["slot"], metric, league) for s in self.gear_slots()] + return sorted(rows, key=lambda r: r.get("headroom") or 0, reverse=True) + + def gear_uniques( + self, metric: str = "balanced", league: str | None = None, max_price: float | None = None + ) -> dict[str, Any]: + """Uniques that would help most per slot, with poe.ninja prices and trade links.""" + groups = self._memoised(("gear.uniques", metric), lambda: self.call("gear.uniques", metric=metric, limit=25)) + league = self.league(league) + prices: dict[str, Any] = {"rates": {}, "items": {}} + if league: + try: + prices = market.unique_prices(league) + except Exception: # noqa: BLE001 - prices are optional + pass + out = [] + for group in groups: + items = [] + for item in group["items"]: + row = dict(item) + price = prices["items"].get(item["name"]) + if price: + row["priceDivine"] = price["divine"] + row["price"] = market.format_price(price["divine"], prices["rates"]) + row["listings"] = price["listings"] + if price["divine"] > 0: + row["gainPerDivine"] = item["score"] / price["divine"] + if max_price is not None and row.get("priceDivine") is not None and row["priceDivine"] > max_price: + continue + if league: + row["tradeUrl"] = market.trade_url(league, market.unique_query(item["name"], item.get("baseName"))) + items.append(row) + if items: + out.append({"slot": group["slot"], "items": items[:8]}) + return {"league": league, "rates": prices["rates"], "slots": out} + + def gems_advice(self, metric: str = "balanced") -> dict[str, Any]: + """Support worth, best replacement per skill, gem level/quality upgrades and setup problems.""" + return self._memoised(("gems.advise", metric), lambda: self.call("gems.advise", metric=metric)) + + def socket_advice(self, metric: str = "balanced", slot: str | None = None, limit: int = 5) -> dict[str, Any]: + """Each socketed rune/soul core/idol's worth and the best legal replacements per socket.""" + return self._memoised( + ("sockets.analyze", metric, slot, limit), + lambda: self.call("sockets.analyze", metric=metric, slot=slot, limit=limit), + ) + + def socket_plan( + self, metric: str = "balanced", max_swaps: int = 8, min_gain: float = 0.5, keep_unmodelled: bool = True + ) -> dict[str, Any]: + """Greedy combined plan of socket swaps, re-measured after each one. Does not change the build.""" + return self._memoised( + ("sockets.optimize", metric, max_swaps, min_gain, keep_unmodelled), + lambda: self.call( + "sockets.optimize", metric=metric, maxSwaps=max_swaps, minGain=min_gain, keepUnmodelled=keep_unmodelled + ), + ) + + def set_gem(self, group: int, index: int, **changes: Any) -> dict[str, Any]: + return self.call("skills.set_gem", group=group, index=index, **changes) + + # -- tree ------------------------------------------------------------------------------------ + + def tree_layout(self) -> dict[str, Any]: + """Static node geometry (cached per tree version) plus the current allocation.""" + info = self.info() + version = info["treeVersion"] + with self._lock: + if version not in self._layout_cache: + layout = self.call("tree.layout") + for node in layout["nodes"]: + node.pop("alloc", None) + self._layout_cache[version] = layout + tree = self.call("tree.get") + allocated = [n["id"] for n in tree["nodes"]] + # Chosen +5 attribute on allocated attribute nodes (Strength/Dexterity/Intelligence). + attributes = {str(n["id"]): n["name"] for n in tree["nodes"] if n.get("isAttribute")} + jewels = { + str(i["jewelNodeId"]): { + k: i["item"].get(k) for k in ("name", "title", "baseName", "rarity", "mods") if i["item"].get(k) + } + for i in self.call("items.list", full=True) + if i.get("jewelNodeId") and i.get("item") + } + return { + **self._layout_cache[version], + "allocated": allocated, + "jewels": jewels, + "attributes": attributes, + "className": tree.get("className"), + "ascendClassName": tree.get("ascendClassName"), + } diff --git a/python/src/pob2/web/__init__.py b/python/src/pob2/web/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/src/pob2/web/app.py b/python/src/pob2/web/app.py new file mode 100644 index 0000000000..e7937b9671 --- /dev/null +++ b/python/src/pob2/web/app.py @@ -0,0 +1,130 @@ +"""REST API + static web UI + MCP endpoint, all sharing one live build. + +Run: uv run --directory python pob2-web -> http://127.0.0.1:8080 (MCP at /mcp) +""" + +from __future__ import annotations + +import argparse +import contextlib +import logging +import os +from pathlib import Path +from typing import Any + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from mcp.server.transport_security import TransportSecuritySettings + +from pob2 import mcp_server +from pob2.codec import CodecError +from pob2.engine import EngineError, find_repo_root +from pob2.web import routes + +STATIC_DIR = Path(__file__).parent / "static" +log = logging.getLogger("pob2.web") + + +def tree_art_dir() -> Path | None: + """PoE2 passive tree artwork generated by scripts/build_tree_art.py (POB2_TREE_ART or /tree-art).""" + env = os.environ.get("POB2_TREE_ART") + if env: + return Path(env) + with contextlib.suppress(EngineError): + return find_repo_root() / "tree-art" + return None + + +def mcp_transport_security(extra_hosts: str | None = None) -> TransportSecuritySettings: + """DNS-rebinding protection for /mcp: localhost plus hosts from POB2_ALLOWED_HOSTS (comma-separated), + e.g. the Tailscale name "pob2.tail1234.ts.net" or a reverse proxy's hostname.""" + raw = os.environ.get("POB2_ALLOWED_HOSTS", "") if extra_hosts is None else extra_hosts + hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*"] + origins = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"] + for host in filter(None, (h.strip() for h in raw.split(","))): + hosts += [host, f"{host}:*"] + origins += [f"https://{host}", f"http://{host}", f"https://{host}:*", f"http://{host}:*"] + return TransportSecuritySettings(allowed_hosts=hosts, allowed_origins=origins) + + +def create_app(with_mcp: bool = True) -> FastAPI: + mcp_app = ( + mcp_server.mcp.streamable_http_app(streamable_http_path="/mcp", transport_security=mcp_transport_security()) + if with_mcp + else None + ) + + @contextlib.asynccontextmanager + async def lifespan(_: FastAPI): # type: ignore[no-untyped-def] + if mcp_app is not None: + async with mcp_server.mcp.session_manager.run(): + yield + else: + yield + if mcp_server._service is not None: + mcp_server._service.engine.stop() + + app = FastAPI(title="PoB2-Enhanced", version="0.1.0", lifespan=lifespan) + app.include_router(routes.router, prefix="/api") + + @app.exception_handler(EngineError) + async def engine_error(_: Request, exc: EngineError) -> JSONResponse: + status = {"bad_request": 400, "not_found": 404, "method_not_found": 404, "timeout": 504}.get(exc.code, 500) + return JSONResponse({"error": str(exc), "code": exc.code}, status_code=status) + + @app.exception_handler(CodecError) + async def codec_error(_: Request, exc: CodecError) -> JSONResponse: + return JSONResponse({"error": str(exc), "code": "bad_request"}, status_code=400) + + @app.exception_handler(ValueError) + async def value_error(_: Request, exc: ValueError) -> JSONResponse: + return JSONResponse({"error": str(exc), "code": "bad_request"}, status_code=400) + + @app.exception_handler(httpx.HTTPError) + async def fetch_error(_: Request, exc: httpx.HTTPError) -> JSONResponse: + return JSONResponse({"error": f"Could not fetch build link: {exc}", "code": "fetch"}, status_code=502) + + if mcp_app is not None: + # Share the MCP routes (and therefore the same PoBService) with the web UI. + app.router.routes.extend(mcp_app.routes) + + art = tree_art_dir() + if art is not None and art.is_dir(): + app.mount("/tree-art", StaticFiles(directory=art), name="tree-art") + else: + log.info("No tree artwork found (run scripts/build_tree_art.py); the tree renders without icons") + + if STATIC_DIR.is_dir(): + app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") + + @app.get("/{path:path}", include_in_schema=False) + async def spa(path: str) -> Any: + file = STATIC_DIR / path + if path and file.is_file() and STATIC_DIR in file.resolve().parents: + return FileResponse(file) + # Always revalidate the page itself so a redeploy is picked up; hashed /assets can be cached. + return FileResponse(STATIC_DIR / "index.html", headers={"Cache-Control": "no-cache"}) + + return app + + +def main(argv: list[str] | None = None) -> None: + import uvicorn + + parser = argparse.ArgumentParser(description="PoB2-Enhanced web UI + REST API + MCP endpoint") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8080) + parser.add_argument("--no-mcp", action="store_true", help="Do not expose the MCP endpoint at /mcp") + parser.add_argument("--reload", action="store_true", help="Auto-reload on code changes (development)") + args = parser.parse_args(argv) + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + if args.reload: + uvicorn.run("pob2.web.app:create_app", factory=True, host=args.host, port=args.port, reload=True) + else: + uvicorn.run(create_app(with_mcp=not args.no_mcp), host=args.host, port=args.port) + + +if __name__ == "__main__": + main() diff --git a/python/src/pob2/web/routes.py b/python/src/pob2/web/routes.py new file mode 100644 index 0000000000..9be1d0cb6e --- /dev/null +++ b/python/src/pob2/web/routes.py @@ -0,0 +1,439 @@ +"""REST endpoints. Sync handlers run in FastAPI's threadpool; the engine serialises calls.""" + +from __future__ import annotations + +from typing import Any, Literal + +from fastapi import APIRouter, Query +from pydantic import BaseModel, Field + +from pob2 import mcp_server + +router = APIRouter() + + +def svc(): # noqa: ANN201 - shared with the MCP server so both see the same build + return mcp_server.svc() + + +# -- models ---------------------------------------------------------------------------------------- + + +class NewBuild(BaseModel): + className: str | None = None + ascendancy: str | None = None + level: int | None = Field(None, ge=1, le=100) + + +class ImportBuild(BaseModel): + source: str = Field(description="Share code, pobb.in/pastebin/poe.ninja link, or XML") + + +class Character(BaseModel): + level: int | None = Field(None, ge=1, le=100) + className: str | None = None + ascendancy: str | None = None + + +class AddSkill(BaseModel): + gems: list[str] + label: str | None = None + makeMain: bool = True + + +class SkillMain(BaseModel): + skill: int | None = None + + +class SkillEnabled(BaseModel): + enabled: bool + + +class ItemText(BaseModel): + text: str + slot: str | None = None + + +class NodeIds(BaseModel): + ids: list[int] + + +class NodeCompare(BaseModel): + add: list[int] | None = None + remove: list[int] | None = None + + +class TreeUrl(BaseModel): + url: str = Field(description="pathofexile2.com/game/passive-skill-tree/ link or its code") + + +class ConfigValues(BaseModel): + values: dict[str, Any] + + +class CustomMods(BaseModel): + text: str + + +class AttributeChange(BaseModel): + id: int + attribute: Literal["Strength", "Dexterity", "Intelligence"] + + +class AttributeChanges(BaseModel): + changes: list[AttributeChange] + + +class GemChange(BaseModel): + name: str | None = None + level: int | None = Field(None, ge=1, le=40) + quality: int | None = Field(None, ge=0, le=40) + enabled: bool | None = None + + +class ItemCheck(BaseModel): + text: str = Field(description="Item text copied from the game (Ctrl+C)") + slot: str | None = Field(None, description="Slot to test in (default: where it does best)") + + +class ReferenceUpload(BaseModel): + content: str = Field(description="Contents of a PoE2 BuildPlanner .build file (JSON)") + + +# -- build ----------------------------------------------------------------------------------------- + + +@router.get("/health") +def health() -> dict[str, Any]: + s = svc() + return {"ok": True, "engineRunning": s.engine.running, "revision": s.revision} + + +@router.get("/revision") +def revision() -> dict[str, int]: + return {"revision": svc().revision} + + +@router.get("/build") +def overview() -> dict[str, Any]: + s = svc() + return {**s.overview(), "revision": s.revision} + + +@router.post("/build/new") +def new_build(body: NewBuild) -> dict[str, Any]: + return svc().new_build(body.className, body.ascendancy, body.level) + + +@router.post("/build/import") +def import_build(body: ImportBuild) -> dict[str, Any]: + return svc().load(body.source) + + +@router.get("/build/export") +def export_build() -> dict[str, str]: + return svc().export() + + +@router.post("/build/character") +def character(body: Character) -> dict[str, Any]: + return svc().set_character(body.level, body.className, body.ascendancy) + + +@router.get("/classes") +def classes() -> Any: + return svc().call("data.classes") + + +# -- stats ----------------------------------------------------------------------------------------- + + +@router.get("/stats") +def stats(keys: list[str] | None = Query(None)) -> Any: + return svc().call("stats.get", keys=keys) + + +@router.get("/stats/summary") +def stat_summary() -> Any: + return svc().call("stats.summary") + + +# -- skills ---------------------------------------------------------------------------------------- + + +@router.get("/skills") +def skills() -> Any: + return svc().call("skills.list") + + +@router.post("/skills") +def add_skill(body: AddSkill) -> Any: + return svc().call("skills.add", gems=body.gems, label=body.label, makeMain=body.makeMain) + + +@router.post("/skills/{group}/main") +def set_main(group: int, body: SkillMain) -> Any: + return svc().call("skills.set_main", group=group, skill=body.skill) + + +@router.patch("/skills/{group}") +def set_enabled(group: int, body: SkillEnabled) -> Any: + return svc().call("skills.set_enabled", group=group, enabled=body.enabled) + + +@router.delete("/skills/{group}") +def remove_skill(group: int) -> Any: + return svc().call("skills.remove", group=group) + + +# -- items ----------------------------------------------------------------------------------------- + + +@router.get("/items") +def items(includeEmpty: bool = False) -> Any: + return svc().call("items.list", includeEmpty=includeEmpty) + + +@router.get("/items/slots") +def item_slots() -> Any: + return svc().call("items.slots") + + +@router.post("/items/equip") +def equip(body: ItemText) -> Any: + return svc().call("items.equip", text=body.text, slot=body.slot) + + +@router.post("/items/compare") +def compare_item(body: ItemText) -> Any: + return svc().call("stats.compare_item", text=body.text, slot=body.slot) + + +@router.post("/items/parse") +def parse_item(body: ItemText) -> Any: + return svc().call("items.parse", text=body.text) + + +@router.delete("/items/{slot}") +def unequip(slot: str) -> Any: + return svc().call("items.unequip", slot=slot) + + +# -- tree ------------------------------------------------------------------------------------------ + + +@router.get("/tree") +def tree() -> Any: + return svc().call("tree.get") + + +@router.get("/tree/layout") +def tree_layout() -> Any: + return svc().tree_layout() + + +@router.get("/tree/search") +def tree_search(q: str, minor: bool = False, limit: int = 25) -> Any: + return svc().call("tree.search", query=q, includeMinor=minor, limit=limit) + + +@router.get("/tree/node/{node_id}") +def tree_node(node_id: int) -> Any: + return svc().call("tree.node", id=node_id) + + +@router.post("/tree/allocate") +def allocate(body: NodeIds) -> Any: + return svc().call("tree.allocate", ids=body.ids) + + +@router.post("/tree/deallocate") +def deallocate(body: NodeIds) -> Any: + return svc().call("tree.deallocate", ids=body.ids) + + +@router.post("/tree/compare") +def compare_nodes(body: NodeCompare) -> Any: + return svc().call("stats.compare_nodes", add=body.add, remove=body.remove) + + +@router.post("/tree/reset") +def reset_tree() -> Any: + return svc().call("tree.reset") + + +@router.post("/tree/import-url") +def import_tree(body: TreeUrl) -> Any: + return svc().call("tree.import_url", url=body.url) + + +@router.get("/tree/export-url") +def export_tree() -> Any: + return svc().call("tree.export_url") + + +# -- config ---------------------------------------------------------------------------------------- + + +@router.get("/config") +def config(q: str | None = None, all: bool = False) -> Any: # noqa: A002 + return svc().call("config.list", query=q, all=all) + + +@router.patch("/config") +def set_config(body: ConfigValues) -> Any: + return svc().call("config.set", values=body.values) + + +@router.get("/config/custom-mods") +def get_custom_mods() -> Any: + return svc().call("config.custom_mods") + + +@router.put("/config/custom-mods") +def put_custom_mods(body: CustomMods) -> Any: + return svc().call("config.custom_mods", text=body.text) + + +@router.post("/tree/attributes") +def set_attributes(body: AttributeChanges) -> Any: + return svc().set_attributes([c.model_dump() for c in body.changes]) + + +@router.get("/advisor") +def advisor( + metric: Literal["balanced", "dps", "ehp"] = "balanced", + level: int | None = Query(None, ge=1, le=100), + limit: int = Query(10, ge=1, le=50), +) -> Any: + return svc().advice(metric, level, limit) + + +@router.get("/build/source") +def build_source() -> Any: + return svc().source_info() + + +@router.post("/build/refresh") +def refresh_build() -> Any: + return svc().refresh() + + +@router.post("/skills/{group}/gems/{index}") +def set_gem(group: int, index: int, body: GemChange) -> Any: + return svc().set_gem(group, index, **body.model_dump(exclude_none=True)) + + +@router.get("/gems/advice") +def gems_advice(metric: Literal["balanced", "dps", "ehp"] = "balanced") -> Any: + return svc().gems_advice(metric) + + +@router.get("/gear/slots") +def gear_slots() -> Any: + return svc().gear_slots() + + +@router.get("/gear/slot") +def gear_slot(slot: str, metric: Literal["balanced", "dps", "ehp"] = "balanced", league: str | None = None) -> Any: + return svc().gear_slot(slot, metric, league) + + +@router.get("/sockets") +def sockets( + metric: Literal["balanced", "dps", "ehp"] = "balanced", slot: str | None = None, limit: int = Query(5, ge=1, le=50) +) -> Any: + return svc().socket_advice(metric, slot, limit) + + +@router.get("/sockets/plan") +def sockets_plan( + metric: Literal["balanced", "dps", "ehp"] = "balanced", + maxSwaps: int = Query(8, ge=1, le=20), # noqa: N803 - query parameter name + minGain: float = Query(0.5, ge=0), # noqa: N803 + keepUnmodelled: bool = True, # noqa: N803 +) -> Any: + return svc().socket_plan(metric, maxSwaps, minGain, keepUnmodelled) + + +@router.get("/gear/uniques") +def gear_uniques( + metric: Literal["balanced", "dps", "ehp"] = "balanced", + league: str | None = None, + maxPrice: float | None = Query(None, ge=0), # noqa: N803 - query parameter name +) -> Any: + return svc().gear_uniques(metric, league, maxPrice) + + +@router.get("/market/leagues") +def market_leagues() -> Any: + from pob2 import market + + return {"leagues": market.leagues(), "default": svc().league()} + + +@router.get("/budget") +def budget() -> Any: + return svc().budget() + + +@router.post("/items/check") +def check_item(body: ItemCheck) -> Any: + return svc().check_item(body.text, body.slot) + + +# -- reference build ------------------------------------------------------------------------------- + + +@router.get("/reference") +def reference() -> Any: + return svc().reference_summary() + + +@router.put("/reference") +def set_reference(body: ReferenceUpload) -> Any: + return svc().set_reference(body.content) + + +@router.delete("/reference") +def clear_reference() -> Any: + svc().clear_reference() + return {"ok": True} + + +@router.get("/reference/compare") +def reference_compare(level: int | None = Query(None, ge=1, le=100)) -> Any: + return svc().reference_compare(level) + + +@router.get("/reference/impact") +def reference_impact(level: int | None = Query(None, ge=1, le=100), limit: int = Query(40, ge=1, le=200)) -> Any: + return svc().reference_impact(level, limit) + + +@router.post("/reference/apply-tree") +def apply_reference_tree(level: int | None = Query(None, ge=1, le=100)) -> Any: + return svc().apply_reference_tree(level) + + +# -- data ------------------------------------------------------------------------------------------ + + +@router.get("/data/gems") +def gems(q: str = "", support: bool | None = None, limit: int = 30) -> Any: + return svc().call("data.gems", query=q, support=support, limit=limit) + + +@router.get("/data/uniques") +def uniques(q: str = "", type: str | None = None, limit: int = 30) -> Any: # noqa: A002 + return svc().call("data.uniques", query=q, type=type, limit=limit) + + +@router.get("/data/uniques/{name}") +def unique(name: str) -> Any: + return svc().call("data.unique", name=name) + + +@router.get("/data/bases") +def bases(q: str = "", type: str | None = None, limit: int = 30) -> Any: # noqa: A002 + return svc().call("data.bases", query=q, type=type, limit=limit) diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000000..d5655b521f --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,35 @@ +import shutil +import subprocess + +import pytest + +from pob2.engine import Engine, find_repo_root +from pob2.service import PoBService + + +def _luajit_supports_pob() -> bool: + exe = shutil.which("luajit") + if not exe: + return False + # PoB uses LuaJIT's 2026 syntax extensions (compound assignment, continue). + probe = "local x = 1; x += 1; for i = 1, 2 do if i == 1 then continue end end" + return subprocess.run([exe, "-e", probe], capture_output=True).returncode == 0 + + +ENGINE_OK = _luajit_supports_pob() + + +def pytest_collection_modifyitems(config, items): + if ENGINE_OK: + return + skip = pytest.mark.skip(reason="LuaJIT with PoB syntax support not installed (scripts/setup-engine.sh)") + for item in items: + if "engine" in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(scope="session") +def service(): + svc = PoBService(Engine(root=find_repo_root())) + yield svc + svc.engine.stop() diff --git a/python/tests/test_codec.py b/python/tests/test_codec.py new file mode 100644 index 0000000000..3b74a7d367 --- /dev/null +++ b/python/tests/test_codec.py @@ -0,0 +1,39 @@ +import pytest + +from pob2 import codec + +XML = '' + + +def test_round_trip(): + code = codec.encode_code(XML) + assert "+" not in code and "/" not in code + assert codec.decode_code(code) == XML + + +def test_decode_tolerates_whitespace_and_missing_padding(): + code = codec.encode_code(XML).rstrip("=") + assert codec.decode_code(f" {code[:10]}\n{code[10:]} ") == XML + + +def test_resolve_passes_xml_through(): + assert codec.resolve(f" {XML}\n") == XML + + +def test_invalid_code(): + with pytest.raises(codec.CodecError): + codec.decode_code("not-a-build") + + +@pytest.mark.parametrize( + ("link", "raw"), + [ + ("https://pobb.in/abc123", "https://pobb.in/abc123/raw"), + ("https://pobb.in/u/someone/abc123/", "https://pobb.in/abc123/raw"), + ("https://pastebin.com/XyZ", "https://pastebin.com/raw/XyZ"), + ("https://poe.ninja/poe2/pob/1a2b", "https://poe.ninja/poe2/pob/raw/1a2b"), + ("https://example.com/x", None), + ], +) +def test_raw_url(link, raw): + assert codec.raw_url(link) == raw diff --git a/python/tests/test_engine.py b/python/tests/test_engine.py new file mode 100644 index 0000000000..28b6c37331 --- /dev/null +++ b/python/tests/test_engine.py @@ -0,0 +1,301 @@ +"""Integration tests against the real PoB engine (LuaJIT worker).""" + +import base64 +import json + +import pytest + +from pob2 import codec +from pob2.engine import EngineError + +pytestmark = pytest.mark.engine + +WAND = """Rarity: Rare +Test Wand +Attuned Wand +-------- +Item Level: 80 +-------- ++80 to maximum Life +50% increased Spell Damage""" + + +@pytest.fixture +def witch(service): + service.new_build("Witch", "Infernalist", 90) + service.call("skills.add", gems=["Fireball"]) + return service + + +def test_new_build(witch): + info = witch.info() + assert info["className"] == "Witch" + assert info["ascendClassName"] == "Infernalist" + assert info["level"] == 90 + assert info["mainSkill"] == "Fireball" + + +def test_summary_has_dps(witch): + rows = {r["stat"]: r for r in witch.call("stats.summary")["player"]} + assert rows["TotalDPS"]["value"] > 0 + assert rows["Life"]["value"] > 0 + + +def test_export_import_round_trip(witch): + before = witch.call("stats.get", keys=["TotalDPS", "Life"]) + code = witch.export()["code"] + witch.new_build() + witch.load(code) + assert witch.call("stats.get", keys=["TotalDPS", "Life"]) == pytest.approx(before) + assert codec.decode_code(code).startswith(" 0 and changes["Life"]["better"] + assert witch.call("stats.get", keys=["Life"])["Life"] == life + + +def test_equip_and_unequip(witch): + witch.call("items.equip", text=WAND) + slots = {s["slot"]: s for s in witch.call("items.list")} + assert slots["Weapon 1"]["item"]["baseName"] == "Attuned Wand" + witch.call("items.unequip", slot="Weapon 1") + assert all(s["slot"] != "Weapon 1" for s in witch.call("items.list")) + + +def test_tree_search_compare_allocate(witch): + hits = witch.call("tree.search", query="Raw Destruction") + node = next(n for n in hits if n["name"] == "Raw Destruction") + preview = witch.call("stats.compare_nodes", add=[node["id"]]) + assert preview["pointDelta"] == node["pathLength"] + dps = next(c for c in preview["changes"] if c["stat"] == "TotalDPS") + tree = witch.call("tree.allocate", ids=[node["id"]]) + assert tree["failed"] == [] + assert tree["pointsUsed"] == node["pathLength"] + assert witch.call("stats.get", keys=["TotalDPS"])["TotalDPS"] == pytest.approx(dps["after"]) + + +def test_tree_url_is_poe2_and_round_trips(witch): + hits = witch.call("tree.search", query="Raw Destruction") + witch.call("tree.allocate", ids=[next(n["id"] for n in hits if n["name"] == "Raw Destruction")]) + before = witch.call("tree.get") + link = witch.call("tree.export_url") + assert link["url"] == "https://pathofexile2.com/game/passive-skill-tree/" + link["code"] + assert base64.urlsafe_b64decode(link["code"])[:4] == b"\x00\x00\x00\x07" + witch.call("tree.reset") + after = witch.call("tree.import_url", url=link["url"]) + assert after["className"] == before["className"] and after["ascendClassName"] == before["ascendClassName"] + assert {n["id"] for n in after["nodes"]} == {n["id"] for n in before["nodes"]} + + +def test_tree_url_official_example(service): + # Empty Druid tree from pathofexile2.com (class 11, first ascendancy). + service.new_build() + tree = service.call("tree.import_url", url="https://pathofexile2.com/game/passive-skill-tree/AAAABwsBAAA=") + assert tree["className"] == "Druid" and tree["pointsUsed"] == 0 + + +def test_tree_layout_has_artwork(service): + layout = service.tree_layout() + assert layout["classes"] and layout["ascendancies"] + notable = next(n for n in layout["nodes"] if n["type"] == "Notable" and not n.get("ascendancy")) + assert notable["icon"].endswith(".dds") and len(notable["frame"]) == 3 and notable["r"] > 0 + assert any(len(e) == 5 for e in layout["edges"]) # orbit arcs + + +def test_reference_build_compare_and_impact(witch): + hits = witch.call("tree.search", query="Raw Destruction") + witch.call("tree.allocate", ids=[next(n["id"] for n in hits if n["name"] == "Raw Destruction")]) + reference = { + "name": "Test guide", + "ascendancy": "Witch1", + "passives": [], + "skills": [ + {"id": "Metadata/Items/Gems/SkillGemFireball", "level_interval": [1, 100]}, + {"id": "Metadata/Items/Gems/SkillGemDefinitelyMissing", "level_interval": [1, 100]}, + ], + "inventory_slots": [{"inventory_id": "Amulet1", "slot_x": 0, "additional_text": "{Any} life amulet"}], + } + witch.set_reference(json.dumps(reference)) + cmp = witch.reference_compare() + assert cmp["skills"][0]["name"] == "Fireball" and cmp["skills"][0]["found"] + assert cmp["unknownGems"] == ["Metadata/Items/Gems/SkillGemDefinitelyMissing"] + assert cmp["items"][0]["slot"] == "Amulet" and cmp["items"][0]["note"] == "Any life amulet" + # The guide takes no passives, so everything allocated is "extra" and respeccing loses DPS. + assert cmp["passives"]["extra"] + impact = witch.reference_impact() + assert impact["fullTree"]["removed"] > 0 and impact["fullTree"]["dpsPercent"] < 0 + witch.clear_reference() + + +def test_advisor_next_point_and_attributes(witch): + advice = witch.advice() + assert advice["mode"] == "free" + step = advice["next"] + assert step and step["path"][0] == step["first"]["id"] and step["path"][-1] == step["target"]["id"] + assert step["reason"] and step["perPoint"] > 0 + + # Allocate the nearest attribute node, then switch it and check the advice explains it. + attr = min( + (n for n in witch.call("tree.search", query="Attribute", includeMinor=True, limit=200) if n.get("isAttribute")), + key=lambda n: n.get("pathLength") or 99, + ) + witch.call("tree.allocate", ids=[attr["id"]]) + tree = witch.set_attributes([{"id": attr["id"], "attribute": "Intelligence"}]) + assert next(n for n in tree["nodes"] if n["id"] == attr["id"])["name"] == "Intelligence" + entry = next(n for n in witch.advice()["attributes"]["nodes"] if n["id"] == attr["id"]) + assert entry["current"] == "Intelligence" and entry["reason"] + + +def test_gear_advice(witch): + witch.call("items.equip", text=WAND) + slots = {s["slot"]: s for s in witch.gear_slots()} + assert "Weapon 1" in slots and "Helmet" in slots # empty standard slots are evaluated too + wand = witch.gear_slot("Weapon 1", league="Standard") + assert wand["item"]["baseName"] == "Attuned Wand" + life = next(m for m in wand["mods"] if "maximum Life" in m["text"]) + assert life["lifeDelta"] > 0 and not life.get("dead") + assert wand["lookFor"] and wand["lookFor"][0]["percent"] > 0 + assert wand["tradeUrl"].startswith("https://www.pathofexile.com/trade2/search/poe2/Standard?q=") + helmet = witch.gear_slot("Helmet") + assert "item" not in helmet and helmet["testBase"] and helmet["headroom"] > 0 + + +def test_gem_advice_and_set_gem(witch): + advice = witch.gems_advice() + fireball = next(g for g in advice["groups"] if "Fireball" in g["skills"]) + gem = next(x for x in fireball["gems"] if x["name"] == "Fireball") + witch.set_gem(fireball["index"], gem["index"], level=1) + advice = witch.gems_advice() + assert any(u["kind"] == "level" and u["name"] == "Fireball" for u in advice["upgrades"]) + + +def test_budget_and_item_check(witch): + budget = witch.budget() + assert {"spirit", "reserved", "free", "effectiveDps", "reservations", "ehp", "life", "resources"} <= budget.keys() + mana = next(r for r in budget["resources"] if r["key"] == "Mana") + assert mana["total"] > 0 and "costPerSecond" in mana # Fireball spends mana + assert "totems" not in budget # not a totem skill + check = witch.check_item(WAND) + assert check["slot"] == "Weapon 1" and check["verdict"] in {"upgrade", "sidegrade", "downgrade"} + fireball = next(s for s in check["skills"] if s["name"] == "Fireball") + assert fireball["after"] >= fireball["before"] and fireball["isMain"] + assert any("Effective HP" in r for r in check["reasons"]) + compare = witch.call("stats.compare_item", text=WAND) + assert ( + "warnings" in compare["budget"] and compare["budget"]["effectiveAfter"] >= compare["budget"]["effectiveBefore"] + ) + + +FOCUS = """Rarity: Rare +Test Focus +Woven Focus +-------- +Item Level: 80 +-------- +50% increased Spell Damage""" + + +def test_item_check_focus_goes_off_hand(witch): + # Upstream maps a Focus to a "Focus" slot that does not exist. + assert witch.check_item(FOCUS)["slot"] == "Weapon 2" + + +SOCKETED_COAT = """Rarity: RARE +Test Coat +Conjurer Mantle +Item Level: 80 +Sockets: S S +Rune: Legacy of Blackbraid +Rune: None ++100 to maximum Life""" + + +def test_socket_advice_and_plan(witch): + witch.call("items.equip", text=SOCKETED_COAT, slot="Body Armour") + advice = witch.socket_advice() + coat = next(s for s in advice["slots"] if s["slot"] == "Body Armour") + first, empty = coat["sockets"] + assert first["current"] == "Legacy of Blackbraid" and "score" in first["worth"] + assert empty["current"] == "None" and empty["legalOptions"] > 0 + # Another "Legacy of" rune shares Blackbraid's character-wide limit, so it is flagged, not offered as legal. + blocked = witch.call("sockets.analyze", slot="Body Armour", limit=200)["slots"][0]["sockets"][1]["alternatives"] + legacies = [a for a in blocked if a["name"].startswith("Legacy of")] + assert legacies and all(a.get("blockedBy") for a in legacies) + # Limits count every equipped item even when only one slot is analysed. + witch.call("items.equip", text="Rarity: RARE\nTest Boots\nIron Greaves\nItem Level: 80\nSockets: S\nRune: None") + boots = witch.call("sockets.analyze", slot="Boots", limit=200)["slots"][0]["sockets"][0]["alternatives"] + legionstride = next(a for a in boots if a["name"] == "Legacy of Legionstride") + assert legionstride["blockedBy"][0]["slot"] == "Body Armour" + before = witch.call("stats.get", keys=["TotalEHP", "Life"]) + plan = witch.socket_plan(max_swaps=2) + assert plan["steps"] and plan["total"]["score"] > 0 + assert witch.call("stats.get", keys=["TotalEHP", "Life"]) == before # nothing changed + + +UNNAMED_HEARTWOOD, ANCESTRAL_BOND = 52374, 45202 + + +def test_budget_spell_totem_cap(service): + # Spell Totem OVERRIDEs the totem limit to 2, which drops Unnamed Heartwood's +1; in game this places 5 (2 x 2 + 1). + service.new_build("Druid", "Oracle", 90) + service.call("tree.allocate", ids=[UNNAMED_HEARTWOOD, ANCESTRAL_BOND]) + service.call( + "skills.add", + gems=[{"name": "Grim Pillars", "level": 20}, {"name": "Spell Totem", "level": 19, "quality": 20}], + ) + service.call("config.custom_mods", text="+296 to Spirit") + budget = service.budget() + assert budget["ancestralBond"] and budget["spirit"] == 396 + assert (budget["totemCap"], budget["totems"], budget["free"]) == (5, 5, 21) + # Totems stay out when another skill is the main skill: Spirit still counts all five. + service.call("skills.add", gems=["Fireball"], makeMain=True) + other = service.budget() + assert "totems" not in other and other["free"] == 21 + service.call("skills.set_main", group=1) + # "# of Summoned Totems" can raise the cap; Spirit still limits the totems. + service.call("config.set", values={"TotemsSummoned": 6}) + budget = service.budget() + assert (budget["totemCap"], budget["totems"], budget["spiritLimited"]) == (6, 5, True) + service.call("config.set", values={"TotemsSummoned": 3}) + assert service.budget()["totems"] == 3 + + +def test_reference_rejects_non_build_files(service): + with pytest.raises(ValueError): + service.set_reference("not json") + with pytest.raises(ValueError): + service.set_reference('{"hello": 1}') + + +def test_config_affects_dps(witch): + base = witch.call("stats.get", keys=["TotalDPS"])["TotalDPS"] + witch.call("config.set", values={"conditionEnemyShocked": True}) + assert witch.call("stats.get", keys=["TotalDPS"])["TotalDPS"] > base + witch.call("config.set", values={"conditionEnemyShocked": False}) + + +def test_unknown_gem_warns(service): + service.new_build() + group = service.call("skills.add", gems=["Fireball", "Definitely Not A Gem"]) + assert group["warnings"] and "Definitely Not A Gem" in group["warnings"][0] + + +def test_errors_are_reported(service): + with pytest.raises(EngineError) as exc: + service.call("items.parse", text="garbage") + assert exc.value.code == "bad_request" + with pytest.raises(EngineError) as exc: + service.call("no.such_method") + assert exc.value.code == "method_not_found" + + +def test_layout(service): + layout = service.tree_layout() + assert len(layout["nodes"]) > 1000 + ids = {n["id"] for n in layout["nodes"]} + assert all(e[0] in ids and e[1] in ids for e in layout["edges"][:200]) diff --git a/python/tests/test_market.py b/python/tests/test_market.py new file mode 100644 index 0000000000..8f59bf5d10 --- /dev/null +++ b/python/tests/test_market.py @@ -0,0 +1,65 @@ +"""poe.ninja / trade helpers that don't need the network.""" + +import json +from urllib.parse import parse_qs, urlparse + +import pytest + +from pob2 import codec, market + + +def test_trade_url_round_trips_query(): + query = market.unique_query("Kaom's Heart", "Conqueror Plate") + url = market.trade_url("Forbidden Rites", query) + parsed = urlparse(url) + assert parsed.path == "/trade2/search/poe2/Forbidden%20Rites" + assert json.loads(parse_qs(parsed.query)["q"][0]) == query + + +@pytest.mark.parametrize( + ("divine", "expected"), + [(3.25, "3.2 div"), (150, "150 div"), (0.5, "353 ex")], +) +def test_format_price(divine, expected): + assert market.format_price(divine, {"exalted": 706.5}) == expected + + +def test_character_url_detection(): + assert market.is_character_url("https://poe.ninja/poe2/builds/forbiddenrites/character/acc-1234/MyChar?i=0") + assert not market.is_character_url("https://poe.ninja/poe2/pob/1a2b") + + +def test_character_url_resolution(monkeypatch): + snapshot = {"url": "forbiddenrites", "name": "Forbidden Rites", "version": "v1", "snapshotName": "fr"} + index = {"snapshotVersions": [snapshot]} + xml = "" + calls = [] + + def fake_get(url, ttl, params=None, timeout=20.0): + calls.append((url, params)) + if url.endswith("index-state"): + return index + return {"pathOfBuildingExport": codec.encode_code(xml)} + + monkeypatch.setattr(market, "_get_json", fake_get) + assert codec.resolve("https://poe.ninja/poe2/builds/forbiddenrites/character/acc-1234/My%20Char") == xml + assert calls[-1][0].endswith("/poe2/api/builds/v1/character") + assert calls[-1][1]["name"] == "My Char" and calls[-1][1]["overview"] == "fr" + + +def test_profile_url_resolution(monkeypatch): + xml = "" + calls = [] + + def fake_get(url, ttl, params=None, timeout=20.0): + calls.append(url) + if url.endswith("index-state"): + return {"economyLeagues": [{"url": "forbiddenrites", "name": "Forbidden Rites"}]} + return {"type": "found", "charModel": {"pathOfBuildingExport": codec.encode_code(xml)}} + + monkeypatch.setattr(market, "_get_json", fake_get) + link = "https://poe.ninja/poe2/profile/Exile-1234/forbiddenrites/character/SomeChar" + assert market.parse_character_url(link) == ("profile", "forbiddenrites", "Exile-1234", "SomeChar") + assert codec.resolve(link) == xml + assert "/poe2/api/profile/characters/Exile-1234/forbiddenrites/SomeChar/model/" in calls[0] + assert market.character_league(link) == "Forbidden Rites" diff --git a/python/tests/test_transport.py b/python/tests/test_transport.py new file mode 100644 index 0000000000..302d0283fe --- /dev/null +++ b/python/tests/test_transport.py @@ -0,0 +1,36 @@ +"""/mcp host allow-list (no engine needed: initialize does not start PoB).""" + +import pytest +from fastapi.testclient import TestClient + +from pob2.web.app import create_app, mcp_transport_security + +INIT = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}, +} +HEADERS = {"accept": "application/json, text/event-stream", "content-type": "application/json"} + + +def test_settings_include_extra_hosts(): + s = mcp_transport_security("pob2.tail1234.ts.net, other.lan") + assert "pob2.tail1234.ts.net" in s.allowed_hosts and "other.lan:*" in s.allowed_hosts + assert "https://pob2.tail1234.ts.net" in s.allowed_origins + assert "localhost:*" in s.allowed_hosts + + +@pytest.mark.parametrize( + ("allowed", "host", "ok"), + [ + ("", "localhost:8080", True), + ("", "pob2.tail1234.ts.net", False), + ("pob2.tail1234.ts.net", "pob2.tail1234.ts.net", True), + ], +) +def test_mcp_host_check(monkeypatch, allowed, host, ok): + monkeypatch.setenv("POB2_ALLOWED_HOSTS", allowed) + with TestClient(create_app(), base_url=f"http://{host}") as client: + r = client.post("/mcp", json=INIT, headers=HEADERS) + assert (r.status_code == 200) is ok, r.text diff --git a/python/tests/test_web.py b/python/tests/test_web.py new file mode 100644 index 0000000000..5293603127 --- /dev/null +++ b/python/tests/test_web.py @@ -0,0 +1,34 @@ +"""REST API smoke tests (uses the real engine).""" + +import pytest +from fastapi.testclient import TestClient + +from pob2 import mcp_server +from pob2.web.app import create_app + +pytestmark = pytest.mark.engine + + +@pytest.fixture(scope="module") +def client(service): + mcp_server._service = service + with TestClient(create_app(with_mcp=False)) as c: + yield c + + +def test_build_flow(client): + r = client.post("/api/build/new", json={"className": "Ranger", "level": 50}) + assert r.status_code == 200 and r.json()["className"] == "Ranger" + rev = client.get("/api/revision").json()["revision"] + assert client.post("/api/skills", json={"gems": ["Lightning Arrow"]}).status_code == 200 + assert client.get("/api/revision").json()["revision"] > rev + overview = client.get("/api/build").json() + assert overview["info"]["level"] == 50 + assert overview["skills"][0]["gems"][0]["name"] == "Lightning Arrow" + + +def test_errors_map_to_http(client): + r = client.post("/api/items/parse", json={"text": "garbage"}) + assert r.status_code == 400 and "parse" in r.json()["error"] + r = client.post("/api/build/import", json={"source": "not a code"}) + assert r.status_code == 400 diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 0000000000..ccf4073f37 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,1128 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "mcp" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/dc/e12c1fe1ed8a7b7149777127b1a0e12ce5bd5a81d97408bedc2128c260f5/opentelemetry_api-1.45.0.tar.gz", hash = "sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7", size = 72115, upload-time = "2026-09-25T12:32:28.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b9/040d1a1c7836922828e6480cd2366bb8fe0ebf75b413d2bb51a9b0e7f78f/opentelemetry_api-1.45.0-py3-none-any.whl", hash = "sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3", size = 60020, upload-time = "2026-09-25T12:32:03.192Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pob2-enhanced" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", specifier = ">=2.2,<3" }, + { name = "uvicorn", specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e", size = 6361387, upload-time = "2026-06-04T07:49:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db", size = 6926780, upload-time = "2026-06-04T07:49:16.881Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd", size = 4307203, upload-time = "2026-06-04T07:49:18.993Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/54/6767bb789b2f2fed6e0f953df949cd39dc263a384c1b65a95232598621d6/sse_starlette-3.4.11.tar.gz", hash = "sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade", size = 34972, upload-time = "2026-09-05T12:11:04.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/6a/2ba3ed4a69babf3afdddf7d8314a48d87562c0a442206bbc2a1b50d5efc0/sse_starlette-3.4.11-py3-none-any.whl", hash = "sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453", size = 17122, upload-time = "2026-09-05T12:11:03.195Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] diff --git a/scripts/build_tree_art.py b/scripts/build_tree_art.py new file mode 100644 index 0000000000..8e9428c56c --- /dev/null +++ b/scripts/build_tree_art.py @@ -0,0 +1,158 @@ +"""Convert PoE2 passive tree artwork (zstd-compressed DDS texture arrays) into WebP for the web UI. + + uv run --no-project --with zstandard --with texture2ddecoder --with pillow \ + python scripts/build_tree_art.py --out tree-art + +Reads src/TreeData//tree.lua's ``ddsCoords`` (atlas file -> {asset name: layer index}) and writes +``//manifest.json`` plus one WebP sprite sheet per small atlas or one WebP per layer for large +ones. Only atlases referenced by ``ddsCoords`` are converted. Defaults to the latest tree version. +""" + +from __future__ import annotations + +import argparse +import json +import math +import re +import struct +import sys +from pathlib import Path + +import texture2ddecoder +import zstandard +from PIL import Image + +DXGI_BC1 = (70, 71, 72) +DXGI_BC7 = (97, 98, 99) +DXGI_RGBA = (27, 28, 29) +SHEET_MAX_SIDE = 256 # layers up to this size are packed into a sprite sheet +MAX_FILE_SIDE = 2048 # larger layers are downscaled (PoB draws them at 2x anyway) + +ATLAS_RE = re.compile(r'^\t\t\["(.+?)"\]=\{\s*$') +ENTRY_RE = re.compile(r'^\t\t\t(?:\["(.+?)"\]|([A-Za-z_][A-Za-z0-9_]*))=(\d+),?\s*$') + + +def parse_dds_coords(tree_lua: Path) -> dict[str, dict[str, int]]: + coords: dict[str, dict[str, int]] = {} + inside = False + current: dict[str, int] | None = None + with tree_lua.open(encoding="utf-8") as f: + for line in f: + if not inside: + inside = line.startswith("\tddsCoords={") + continue + if line.startswith("\t}"): + break + if m := ATLAS_RE.match(line): + current = coords.setdefault(m.group(1), {}) + elif current is not None and (m := ENTRY_RE.match(line)): + current[m.group(1) or m.group(2)] = int(m.group(3)) + return coords + + +def latest_version(root: Path) -> str: + text = (root / "src" / "GameVersions.lua").read_text(encoding="utf-8") + versions = re.search(r"treeVersionList\s*=\s*\{([^}]*)\}", text) + if not versions: + sys.exit("treeVersionList not found in GameVersions.lua") + return re.findall(r'"([^"]+)"', versions.group(1))[-1] + + +def mip_bytes(w: int, h: int, dxgi: int) -> int: + if dxgi in DXGI_RGBA: + return w * h * 4 + block = 8 if dxgi in DXGI_BC1 else 16 + return max(1, (w + 3) // 4) * max(1, (h + 3) // 4) * block + + +def read_layers(path: Path, wanted: set[int] | None) -> tuple[int, int, dict[int, Image.Image]]: + raw = zstandard.ZstdDecompressor().stream_reader(path.open("rb")).read() + if raw[:4] != b"DDS ": + raise ValueError(f"{path.name}: not a DDS file") + height, width = struct.unpack_from("<2I", raw, 12) + mips = max(1, struct.unpack_from("> m), max(1, height >> m), dxgi) for m in range(mips)) + top = mip_bytes(width, height, dxgi) + images: dict[int, Image.Image] = {} + for layer in range(layers): + index = layer + 1 # ddsCoords is 1-based + if wanted is None or index in wanted: + data = raw[offset : offset + top] + if dxgi in DXGI_BC1: + rgba = Image.frombytes( + "RGBA", (width, height), texture2ddecoder.decode_bc1(data, width, height), "raw", "BGRA" + ) + elif dxgi in DXGI_BC7: + rgba = Image.frombytes( + "RGBA", (width, height), texture2ddecoder.decode_bc7(data, width, height), "raw", "BGRA" + ) + elif dxgi in DXGI_RGBA: + rgba = Image.frombytes("RGBA", (width, height), data) + else: + raise ValueError(f"{path.name}: unsupported DXGI format {dxgi}") + images[index] = rgba + offset += slice_size + return width, height, images + + +def convert_version(root: Path, version: str, out: Path) -> None: + tree_dir = root / "src" / "TreeData" / version + coords = parse_dds_coords(tree_dir / "tree.lua") + if not coords: + sys.exit(f"No ddsCoords in {tree_dir / 'tree.lua'}") + dest = out / version + dest.mkdir(parents=True, exist_ok=True) + atlases: dict[str, dict] = {} + names: dict[str, list[list]] = {} + for file, entries in sorted(coords.items()): + src = tree_dir / file + if not src.is_file(): + print(f" missing {file}, skipped", file=sys.stderr) + continue + atlas_id = file.removesuffix(".dds.zst") + small = max(int(n) for n in re.findall(r"_(\d+)_(\d+)_", file)[0]) <= SHEET_MAX_SIDE + w, h, images = read_layers(src, None if small else set(entries.values())) + info: dict = {"w": w, "h": h} + if small: + count = max(images) + cols = math.ceil(math.sqrt(count)) + sheet = Image.new("RGBA", (cols * w, math.ceil(count / cols) * h)) + for index, img in images.items(): + if index <= count: + sheet.paste(img, (((index - 1) % cols) * w, ((index - 1) // cols) * h)) + sheet.save(dest / f"{atlas_id}.webp", "WEBP", quality=92, method=4) + info.update(kind="sheet", cols=cols) + else: + (dest / atlas_id).mkdir(exist_ok=True) + for index, img in images.items(): + if max(w, h) > MAX_FILE_SIDE: + f = MAX_FILE_SIDE / max(w, h) + img = img.resize((round(w * f), round(h * f)), Image.LANCZOS) + img.save(dest / atlas_id / f"{index}.webp", "WEBP", quality=85, method=2) + info.update(kind="files") + atlases[atlas_id] = info + for name, index in entries.items(): + names.setdefault(name, []).append([atlas_id, index]) + print(f" {atlas_id}: {len(entries)} assets, {w}x{h}, {info['kind']}") + manifest = {"version": version, "atlases": atlases, "names": names} + (dest / "manifest.json").write_text(json.dumps(manifest, separators=(",", ":")), encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1], help="Repository root") + parser.add_argument("--out", type=Path, default=None, help="Output directory (default: /tree-art)") + parser.add_argument("--versions", nargs="*", help="Tree versions to convert (default: latest)") + args = parser.parse_args() + out = args.out or args.root / "tree-art" + for version in args.versions or [latest_version(args.root)]: + print(f"Tree {version}:") + convert_version(args.root, version, out) + + +if __name__ == "__main__": + main() diff --git a/scripts/setup-engine.sh b/scripts/setup-engine.sh new file mode 100755 index 0000000000..8c6939f7a7 --- /dev/null +++ b/scripts/setup-engine.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Build the LuaJIT + lua-utf8 runtime the headless PoB engine needs, then set up Python and the web UI. +# +# scripts/setup-engine.sh # installs into ~/.local +# scripts/setup-engine.sh --runtime-only # LuaJIT + lua-utf8 only (skip Python and web UI) +# PREFIX=/usr/local sudo -E scripts/setup-engine.sh +# +# PoB uses LuaJIT syntax extensions added on 2026-07-20 (commit 2460b3ff: +=, continue, lambdas), +# so distro LuaJIT packages are too old. This pins the same commit as SimpleGraphic and the Dockerfile. +set -euo pipefail + +RUNTIME_ONLY=0 +[[ "${1:-}" == "--runtime-only" ]] && RUNTIME_ONLY=1 + +LUAJIT_COMMIT="${LUAJIT_COMMIT:-2460b3ff93a1c955de3d62cfc825de7d68dc272e}" +LUAUTF8_TAG="${LUAUTF8_TAG:-0.1.6}" +PREFIX="${PREFIX:-$HOME/.local}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +log() { printf '\033[1;33m==>\033[0m %s\n' "$*"; } + +probe='local x = 1; x += 1; for i = 1, 2 do if i == 1 then continue end end' +if [[ -x "$PREFIX/bin/luajit" ]] && "$PREFIX/bin/luajit" -e "$probe" 2>/dev/null; then + log "LuaJIT with PoB syntax support already in $PREFIX/bin" +else + log "Building LuaJIT $LUAJIT_COMMIT into $PREFIX" + git clone --quiet https://github.com/LuaJIT/LuaJIT "$WORK/luajit" + git -C "$WORK/luajit" checkout --quiet "$LUAJIT_COMMIT" + make -C "$WORK/luajit" -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" PREFIX="$PREFIX" >/dev/null + make -C "$WORK/luajit" install PREFIX="$PREFIX" >/dev/null + ln -sf "$(ls "$PREFIX"/bin/luajit-2.1.* | sort | tail -1)" "$PREFIX/bin/luajit" +fi + +CMOD_DIR="$PREFIX/lib/lua/5.1" +if [[ -f "$CMOD_DIR/lua-utf8.so" ]]; then + log "lua-utf8 already installed" +else + log "Building lua-utf8 $LUAUTF8_TAG" + git clone --quiet --depth 1 --branch "$LUAUTF8_TAG" https://github.com/starwing/luautf8 "$WORK/luautf8" + mkdir -p "$CMOD_DIR" + shared="-shared" + [[ "$(uname)" == "Darwin" ]] && shared="-bundle -undefined dynamic_lookup" + # shellcheck disable=SC2086 + cc -O2 -fPIC $shared -I"$PREFIX/include/luajit-2.1" "$WORK/luautf8/lutf8lib.c" -o "$CMOD_DIR/lua-utf8.so" +fi + +"$PREFIX/bin/luajit" -e "$probe; require('lua-utf8')" && log "LuaJIT runtime OK" +if [[ $RUNTIME_ONLY == 1 ]]; then + exit 0 +fi + +if command -v uv >/dev/null; then + log "Installing Python package (uv sync)" + uv sync --directory "$REPO_ROOT/python" --quiet +else + log "uv not found; install it from https://docs.astral.sh/uv/ and run: uv sync --directory python" +fi + +if command -v npm >/dev/null; then + log "Building web UI" + (cd "$REPO_ROOT/web" && npm ci --no-audit --no-fund --silent && npm run build --silent) +else + log "npm not found; the web UI will not be built (the REST API and MCP server still work)" +fi + +case ":$PATH:" in + *":$PREFIX/bin:"*) ;; + *) log "Add $PREFIX/bin to PATH, or export POB2_LUAJIT=$PREFIX/bin/luajit" ;; +esac +log "Done. Try: uv run --directory python pob2-web" diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/1.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/1.webp new file mode 100644 index 0000000000..df8e7bd332 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/1.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/10.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/10.webp new file mode 100644 index 0000000000..53a9f174d8 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/10.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/11.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/11.webp new file mode 100644 index 0000000000..de93aa273a Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/11.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/12.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/12.webp new file mode 100644 index 0000000000..282cfda336 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/12.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/13.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/13.webp new file mode 100644 index 0000000000..2bdce17b95 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/13.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/14.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/14.webp new file mode 100644 index 0000000000..ae43faffaf Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/14.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/15.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/15.webp new file mode 100644 index 0000000000..268594836b Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/15.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/16.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/16.webp new file mode 100644 index 0000000000..b1eb749f4c Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/16.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/17.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/17.webp new file mode 100644 index 0000000000..8191e6528d Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/17.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/18.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/18.webp new file mode 100644 index 0000000000..c155285171 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/18.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/19.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/19.webp new file mode 100644 index 0000000000..00c980dd2f Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/19.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/2.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/2.webp new file mode 100644 index 0000000000..eac1be57e8 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/2.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/20.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/20.webp new file mode 100644 index 0000000000..4cefcec74b Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/20.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/21.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/21.webp new file mode 100644 index 0000000000..08e05ab2a8 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/21.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/22.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/22.webp new file mode 100644 index 0000000000..5680aaf975 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/22.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/23.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/23.webp new file mode 100644 index 0000000000..268594836b Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/23.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/24.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/24.webp new file mode 100644 index 0000000000..b1a1db4525 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/24.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/25.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/25.webp new file mode 100644 index 0000000000..d6e3499def Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/25.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/26.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/26.webp new file mode 100644 index 0000000000..7d96d36fbe Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/26.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/27.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/27.webp new file mode 100644 index 0000000000..e7012a1377 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/27.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/28.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/28.webp new file mode 100644 index 0000000000..02bf3a97f7 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/28.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/29.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/29.webp new file mode 100644 index 0000000000..268594836b Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/29.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/3.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/3.webp new file mode 100644 index 0000000000..841379d553 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/3.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/30.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/30.webp new file mode 100644 index 0000000000..e9c3fdde80 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/30.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/31.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/31.webp new file mode 100644 index 0000000000..a999212769 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/31.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/32.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/32.webp new file mode 100644 index 0000000000..65f6eea8fa Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/32.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/33.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/33.webp new file mode 100644 index 0000000000..e956f7aa15 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/33.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/34.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/34.webp new file mode 100644 index 0000000000..a5e931c270 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/34.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/35.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/35.webp new file mode 100644 index 0000000000..ad2199ee62 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/35.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/4.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/4.webp new file mode 100644 index 0000000000..9229d9cb7f Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/4.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/5.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/5.webp new file mode 100644 index 0000000000..9ab977122b Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/5.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/6.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/6.webp new file mode 100644 index 0000000000..5f9bd05def Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/6.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/7.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/7.webp new file mode 100644 index 0000000000..3938098fca Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/7.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/8.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/8.webp new file mode 100644 index 0000000000..268594836b Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/8.webp differ diff --git a/tree-art/0_5/ascendancy-background_1500_1500_BC7/9.webp b/tree-art/0_5/ascendancy-background_1500_1500_BC7/9.webp new file mode 100644 index 0000000000..6d5c6287ca Binary files /dev/null and b/tree-art/0_5/ascendancy-background_1500_1500_BC7/9.webp differ diff --git a/tree-art/0_5/ascendancy-background_4008_4008_BC7/1.webp b/tree-art/0_5/ascendancy-background_4008_4008_BC7/1.webp new file mode 100644 index 0000000000..9b119d4b22 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_4008_4008_BC7/1.webp differ diff --git a/tree-art/0_5/ascendancy-background_4008_4008_BC7/2.webp b/tree-art/0_5/ascendancy-background_4008_4008_BC7/2.webp new file mode 100644 index 0000000000..907ebe6bb4 Binary files /dev/null and b/tree-art/0_5/ascendancy-background_4008_4008_BC7/2.webp differ diff --git a/tree-art/0_5/background_1048_1048_BC7/1.webp b/tree-art/0_5/background_1048_1048_BC7/1.webp new file mode 100644 index 0000000000..371187dc88 Binary files /dev/null and b/tree-art/0_5/background_1048_1048_BC7/1.webp differ diff --git a/tree-art/0_5/group-background_100_100_BC7.webp b/tree-art/0_5/group-background_100_100_BC7.webp new file mode 100644 index 0000000000..c862d2b7ab Binary files /dev/null and b/tree-art/0_5/group-background_100_100_BC7.webp differ diff --git a/tree-art/0_5/group-background_112_112_BC7.webp b/tree-art/0_5/group-background_112_112_BC7.webp new file mode 100644 index 0000000000..1843ec48df Binary files /dev/null and b/tree-art/0_5/group-background_112_112_BC7.webp differ diff --git a/tree-art/0_5/group-background_160_164_BC7.webp b/tree-art/0_5/group-background_160_164_BC7.webp new file mode 100644 index 0000000000..6a75512e87 Binary files /dev/null and b/tree-art/0_5/group-background_160_164_BC7.webp differ diff --git a/tree-art/0_5/group-background_164_164_BC7.webp b/tree-art/0_5/group-background_164_164_BC7.webp new file mode 100644 index 0000000000..5fffdb2487 Binary files /dev/null and b/tree-art/0_5/group-background_164_164_BC7.webp differ diff --git a/tree-art/0_5/group-background_168_168_BC7.webp b/tree-art/0_5/group-background_168_168_BC7.webp new file mode 100644 index 0000000000..1713607631 Binary files /dev/null and b/tree-art/0_5/group-background_168_168_BC7.webp differ diff --git a/tree-art/0_5/group-background_168_172_BC7.webp b/tree-art/0_5/group-background_168_172_BC7.webp new file mode 100644 index 0000000000..3b086766f9 Binary files /dev/null and b/tree-art/0_5/group-background_168_172_BC7.webp differ diff --git a/tree-art/0_5/group-background_216_216_BC7.webp b/tree-art/0_5/group-background_216_216_BC7.webp new file mode 100644 index 0000000000..60acc96bb0 Binary files /dev/null and b/tree-art/0_5/group-background_216_216_BC7.webp differ diff --git a/tree-art/0_5/group-background_228_232_BC7.webp b/tree-art/0_5/group-background_228_232_BC7.webp new file mode 100644 index 0000000000..149760e152 Binary files /dev/null and b/tree-art/0_5/group-background_228_232_BC7.webp differ diff --git a/tree-art/0_5/group-background_368_368_BC7/1.webp b/tree-art/0_5/group-background_368_368_BC7/1.webp new file mode 100644 index 0000000000..a46060630f Binary files /dev/null and b/tree-art/0_5/group-background_368_368_BC7/1.webp differ diff --git a/tree-art/0_5/group-background_476_476_BC7/1.webp b/tree-art/0_5/group-background_476_476_BC7/1.webp new file mode 100644 index 0000000000..406d8dec91 Binary files /dev/null and b/tree-art/0_5/group-background_476_476_BC7/1.webp differ diff --git a/tree-art/0_5/group-background_536_536_BC7/1.webp b/tree-art/0_5/group-background_536_536_BC7/1.webp new file mode 100644 index 0000000000..1154e92f72 Binary files /dev/null and b/tree-art/0_5/group-background_536_536_BC7/1.webp differ diff --git a/tree-art/0_5/group-background_748_392_BC7/1.webp b/tree-art/0_5/group-background_748_392_BC7/1.webp new file mode 100644 index 0000000000..91c8ec1711 Binary files /dev/null and b/tree-art/0_5/group-background_748_392_BC7/1.webp differ diff --git a/tree-art/0_5/jewel-sockets_160_164_BC7.webp b/tree-art/0_5/jewel-sockets_160_164_BC7.webp new file mode 100644 index 0000000000..cfb64db4c2 Binary files /dev/null and b/tree-art/0_5/jewel-sockets_160_164_BC7.webp differ diff --git a/tree-art/0_5/legion_1032_1032_BC7/1.webp b/tree-art/0_5/legion_1032_1032_BC7/1.webp new file mode 100644 index 0000000000..3ecadb9873 Binary files /dev/null and b/tree-art/0_5/legion_1032_1032_BC7/1.webp differ diff --git a/tree-art/0_5/legion_128_128_BC1.webp b/tree-art/0_5/legion_128_128_BC1.webp new file mode 100644 index 0000000000..f7364fd1de Binary files /dev/null and b/tree-art/0_5/legion_128_128_BC1.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/1.webp b/tree-art/0_5/legion_572_572_BC7/1.webp new file mode 100644 index 0000000000..1f0fc7b23f Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/1.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/10.webp b/tree-art/0_5/legion_572_572_BC7/10.webp new file mode 100644 index 0000000000..02620e6378 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/10.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/11.webp b/tree-art/0_5/legion_572_572_BC7/11.webp new file mode 100644 index 0000000000..4b731c9167 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/11.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/12.webp b/tree-art/0_5/legion_572_572_BC7/12.webp new file mode 100644 index 0000000000..9b29f52ee1 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/12.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/2.webp b/tree-art/0_5/legion_572_572_BC7/2.webp new file mode 100644 index 0000000000..3940aad01f Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/2.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/3.webp b/tree-art/0_5/legion_572_572_BC7/3.webp new file mode 100644 index 0000000000..95294aca3f Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/3.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/4.webp b/tree-art/0_5/legion_572_572_BC7/4.webp new file mode 100644 index 0000000000..8d6c93e6a9 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/4.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/5.webp b/tree-art/0_5/legion_572_572_BC7/5.webp new file mode 100644 index 0000000000..cca19f56b6 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/5.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/6.webp b/tree-art/0_5/legion_572_572_BC7/6.webp new file mode 100644 index 0000000000..ae9a32522d Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/6.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/7.webp b/tree-art/0_5/legion_572_572_BC7/7.webp new file mode 100644 index 0000000000..c786bb282d Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/7.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/8.webp b/tree-art/0_5/legion_572_572_BC7/8.webp new file mode 100644 index 0000000000..3bf6501a30 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/8.webp differ diff --git a/tree-art/0_5/legion_572_572_BC7/9.webp b/tree-art/0_5/legion_572_572_BC7/9.webp new file mode 100644 index 0000000000..f3f62e77b7 Binary files /dev/null and b/tree-art/0_5/legion_572_572_BC7/9.webp differ diff --git a/tree-art/0_5/legion_64_64_BC1.webp b/tree-art/0_5/legion_64_64_BC1.webp new file mode 100644 index 0000000000..849d1e7ef6 Binary files /dev/null and b/tree-art/0_5/legion_64_64_BC1.webp differ diff --git a/tree-art/0_5/manifest.json b/tree-art/0_5/manifest.json new file mode 100644 index 0000000000..2d03148bf1 --- /dev/null +++ b/tree-art/0_5/manifest.json @@ -0,0 +1 @@ +{"version":"0_5","atlases":{"ascendancy-background_1500_1500_BC7":{"w":1500,"h":1500,"kind":"files"},"ascendancy-background_4008_4008_BC7":{"w":4008,"h":4008,"kind":"files"},"background_1048_1048_BC7":{"w":1048,"h":1048,"kind":"files"},"group-background_100_100_BC7":{"w":100,"h":100,"kind":"sheet","cols":1},"group-background_112_112_BC7":{"w":112,"h":112,"kind":"sheet","cols":3},"group-background_160_164_BC7":{"w":160,"h":164,"kind":"sheet","cols":5},"group-background_164_164_BC7":{"w":164,"h":164,"kind":"sheet","cols":1},"group-background_168_168_BC7":{"w":168,"h":168,"kind":"sheet","cols":2},"group-background_168_172_BC7":{"w":168,"h":172,"kind":"sheet","cols":3},"group-background_216_216_BC7":{"w":216,"h":216,"kind":"sheet","cols":3},"group-background_228_232_BC7":{"w":228,"h":232,"kind":"sheet","cols":2},"group-background_368_368_BC7":{"w":368,"h":368,"kind":"files"},"group-background_476_476_BC7":{"w":476,"h":476,"kind":"files"},"group-background_536_536_BC7":{"w":536,"h":536,"kind":"files"},"group-background_748_392_BC7":{"w":748,"h":392,"kind":"files"},"jewel-sockets_160_164_BC7":{"w":160,"h":164,"kind":"sheet","cols":5},"legion_1032_1032_BC7":{"w":1032,"h":1032,"kind":"files"},"legion_128_128_BC1":{"w":128,"h":128,"kind":"sheet","cols":7},"legion_572_572_BC7":{"w":572,"h":572,"kind":"files"},"legion_64_64_BC1":{"w":64,"h":64,"kind":"sheet","cols":3},"mastery-active-effect_784_776_BC7":{"w":784,"h":776,"kind":"files"},"oils_108_108_RGBA":{"w":108,"h":108,"kind":"sheet","cols":4},"skills-disabled_128_128_BC1":{"w":128,"h":128,"kind":"sheet","cols":19},"skills-disabled_172_172_BC1":{"w":172,"h":172,"kind":"sheet","cols":1},"skills-disabled_176_176_BC1":{"w":176,"h":176,"kind":"sheet","cols":1},"skills-disabled_64_64_BC1":{"w":64,"h":64,"kind":"sheet","cols":14},"skills_128_128_BC1":{"w":128,"h":128,"kind":"sheet","cols":19},"skills_172_172_BC1":{"w":172,"h":172,"kind":"sheet","cols":1},"skills_176_176_BC1":{"w":176,"h":176,"kind":"sheet","cols":1},"skills_64_64_BC1":{"w":64,"h":64,"kind":"sheet","cols":14}},"names":{"ClassesAbyssal Lich":[["ascendancy-background_1500_1500_BC7",13]],"ClassesAcolyte of Chayula":[["ascendancy-background_1500_1500_BC7",1]],"ClassesAmazon":[["ascendancy-background_1500_1500_BC7",2]],"ClassesBlood Mage":[["ascendancy-background_1500_1500_BC7",3]],"ClassesChronomancer":[["ascendancy-background_1500_1500_BC7",4]],"ClassesDeadeye":[["ascendancy-background_1500_1500_BC7",5]],"ClassesDisciple of Varashta":[["ascendancy-background_1500_1500_BC7",6]],"ClassesDruid":[["ascendancy-background_1500_1500_BC7",7]],"ClassesDuelist":[["ascendancy-background_1500_1500_BC7",8]],"ClassesGemling Legionnaire":[["ascendancy-background_1500_1500_BC7",9]],"ClassesHuntress":[["ascendancy-background_1500_1500_BC7",10]],"ClassesInfernalist":[["ascendancy-background_1500_1500_BC7",11]],"ClassesInvoker":[["ascendancy-background_1500_1500_BC7",12]],"ClassesLich":[["ascendancy-background_1500_1500_BC7",14]],"ClassesMarauder":[["ascendancy-background_1500_1500_BC7",15]],"ClassesMartial Artist":[["ascendancy-background_1500_1500_BC7",16]],"ClassesMercenary":[["ascendancy-background_1500_1500_BC7",17]],"ClassesMonk":[["ascendancy-background_1500_1500_BC7",18]],"ClassesOracle":[["ascendancy-background_1500_1500_BC7",19]],"ClassesPathfinder":[["ascendancy-background_1500_1500_BC7",20]],"ClassesRanger":[["ascendancy-background_1500_1500_BC7",22]],"ClassesRitualist":[["ascendancy-background_1500_1500_BC7",21]],"ClassesShadow":[["ascendancy-background_1500_1500_BC7",23]],"ClassesShaman":[["ascendancy-background_1500_1500_BC7",24]],"ClassesSmith of Kitava":[["ascendancy-background_1500_1500_BC7",25]],"ClassesSorceress":[["ascendancy-background_1500_1500_BC7",26]],"ClassesSpirit Walker":[["ascendancy-background_1500_1500_BC7",33]],"ClassesStormweaver":[["ascendancy-background_1500_1500_BC7",27]],"ClassesTactician":[["ascendancy-background_1500_1500_BC7",28]],"ClassesTemplar":[["ascendancy-background_1500_1500_BC7",29]],"ClassesTitan":[["ascendancy-background_1500_1500_BC7",30]],"ClassesWarbringer":[["ascendancy-background_1500_1500_BC7",31]],"ClassesWarrior":[["ascendancy-background_1500_1500_BC7",32]],"ClassesWitch":[["ascendancy-background_1500_1500_BC7",34]],"ClassesWitchhunter":[["ascendancy-background_1500_1500_BC7",35]],"BGTree":[["ascendancy-background_4008_4008_BC7",1]],"BGTreeActive":[["ascendancy-background_4008_4008_BC7",2]],"Background2":[["background_1048_1048_BC7",1]],"AscendancyMiddle":[["group-background_100_100_BC7",1]],"PSSkillFrame":[["group-background_112_112_BC7",6]],"PSSkillFrameActive":[["group-background_112_112_BC7",4]],"PSSkillFrameHighlighted":[["group-background_112_112_BC7",5]],"art/textures/interface/2d/2dart/uiimages/ingame/oraclepassiveskillscreenpassiveframeactive.dds":[["group-background_112_112_BC7",1]],"art/textures/interface/2d/2dart/uiimages/ingame/oraclepassiveskillscreenpassiveframecanallocate.dds":[["group-background_112_112_BC7",2]],"art/textures/interface/2d/2dart/uiimages/ingame/oraclepassiveskillscreenpassiveframenormal.dds":[["group-background_112_112_BC7",3]],"JewelFrameAllocated":[["group-background_160_164_BC7",16]],"JewelFrameCanAllocate":[["group-background_160_164_BC7",17]],"JewelFrameUnallocated":[["group-background_160_164_BC7",18]],"NotableFrameAllocated":[["group-background_160_164_BC7",13]],"NotableFrameCanAllocate":[["group-background_160_164_BC7",14]],"NotableFrameUnallocated":[["group-background_160_164_BC7",15]],"art/textures/interface/2d/2dart/uiimages/ingame/abyss/abysslichpassiveskillscreenjewelsocketactive.dds":[["group-background_160_164_BC7",1]],"art/textures/interface/2d/2dart/uiimages/ingame/abyss/abysslichpassiveskillscreenjewelsocketcanallocate.dds":[["group-background_160_164_BC7",2]],"art/textures/interface/2d/2dart/uiimages/ingame/abyss/abysslichpassiveskillscreenjewelsocketnormal.dds":[["group-background_160_164_BC7",3]],"art/textures/interface/2d/2dart/uiimages/ingame/deliriumpassiveskillscreenjewelsocketactive.dds":[["group-background_160_164_BC7",4]],"art/textures/interface/2d/2dart/uiimages/ingame/deliriumpassiveskillscreenjewelsocketcanallocate.dds":[["group-background_160_164_BC7",5]],"art/textures/interface/2d/2dart/uiimages/ingame/deliriumpassiveskillscreenjewelsocketnormal.dds":[["group-background_160_164_BC7",6]],"art/textures/interface/2d/2dart/uiimages/ingame/lichpassiveskillscreenjewelsocketactive.dds":[["group-background_160_164_BC7",7]],"art/textures/interface/2d/2dart/uiimages/ingame/lichpassiveskillscreenjewelsocketcanallocate.dds":[["group-background_160_164_BC7",8]],"art/textures/interface/2d/2dart/uiimages/ingame/lichpassiveskillscreenjewelsocketnormal.dds":[["group-background_160_164_BC7",9]],"art/textures/interface/2d/2dart/uiimages/ingame/oraclepassiveskillscreennotableframeactive.dds":[["group-background_160_164_BC7",10]],"art/textures/interface/2d/2dart/uiimages/ingame/oraclepassiveskillscreennotableframecanallocate.dds":[["group-background_160_164_BC7",11]],"art/textures/interface/2d/2dart/uiimages/ingame/oraclepassiveskillscreennotableframenormal.dds":[["group-background_160_164_BC7",12]],"art/textures/interface/2d/2dart/uiimages/ingame/delirium/voicesjewel/voicesjewelframe.dds":[["group-background_164_164_BC7",1]],"art/textures/interface/2d/2dart/uiimages/ingame/anointpassiveskillscreenframelargeallocated.dds":[["group-background_168_168_BC7",1]],"art/textures/interface/2d/2dart/uiimages/ingame/anointpassiveskillscreenframelargecanallocate.dds":[["group-background_168_168_BC7",2]],"art/textures/interface/2d/2dart/uiimages/ingame/anointpassiveskillscreenframelargenormal.dds":[["group-background_168_168_BC7",3]],"Abyssal LichFrameSmallAllocated":[["group-background_168_172_BC7",1]],"Abyssal LichFrameSmallCanAllocate":[["group-background_168_172_BC7",2]],"Abyssal LichFrameSmallNormal":[["group-background_168_172_BC7",3]],"Acolyte of ChayulaFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Acolyte of ChayulaFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Acolyte of ChayulaFrameSmallNormal":[["group-background_168_172_BC7",6]],"AmazonFrameSmallAllocated":[["group-background_168_172_BC7",4]],"AmazonFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"AmazonFrameSmallNormal":[["group-background_168_172_BC7",6]],"Blood MageFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Blood MageFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Blood MageFrameSmallNormal":[["group-background_168_172_BC7",6]],"ChronomancerFrameSmallAllocated":[["group-background_168_172_BC7",4]],"ChronomancerFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"ChronomancerFrameSmallNormal":[["group-background_168_172_BC7",6]],"DeadeyeFrameSmallAllocated":[["group-background_168_172_BC7",4]],"DeadeyeFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"DeadeyeFrameSmallNormal":[["group-background_168_172_BC7",6]],"Disciple of VarashtaFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Disciple of VarashtaFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Disciple of VarashtaFrameSmallNormal":[["group-background_168_172_BC7",6]],"Gemling LegionnaireFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Gemling LegionnaireFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Gemling LegionnaireFrameSmallNormal":[["group-background_168_172_BC7",6]],"InfernalistFrameSmallAllocated":[["group-background_168_172_BC7",4]],"InfernalistFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"InfernalistFrameSmallNormal":[["group-background_168_172_BC7",6]],"InvokerFrameSmallAllocated":[["group-background_168_172_BC7",4]],"InvokerFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"InvokerFrameSmallNormal":[["group-background_168_172_BC7",6]],"LichFrameSmallAllocated":[["group-background_168_172_BC7",4]],"LichFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"LichFrameSmallNormal":[["group-background_168_172_BC7",6]],"Martial ArtistFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Martial ArtistFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Martial ArtistFrameSmallNormal":[["group-background_168_172_BC7",6]],"OracleFrameSmallAllocated":[["group-background_168_172_BC7",4]],"OracleFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"OracleFrameSmallNormal":[["group-background_168_172_BC7",6]],"PathfinderFrameSmallAllocated":[["group-background_168_172_BC7",4]],"PathfinderFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"PathfinderFrameSmallNormal":[["group-background_168_172_BC7",6]],"RitualistFrameSmallAllocated":[["group-background_168_172_BC7",4]],"RitualistFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"RitualistFrameSmallNormal":[["group-background_168_172_BC7",6]],"ShamanFrameSmallAllocated":[["group-background_168_172_BC7",4]],"ShamanFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"ShamanFrameSmallNormal":[["group-background_168_172_BC7",6]],"Smith of KitavaFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Smith of KitavaFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Smith of KitavaFrameSmallNormal":[["group-background_168_172_BC7",6]],"Spirit WalkerFrameSmallAllocated":[["group-background_168_172_BC7",4]],"Spirit WalkerFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"Spirit WalkerFrameSmallNormal":[["group-background_168_172_BC7",6]],"StormweaverFrameSmallAllocated":[["group-background_168_172_BC7",4]],"StormweaverFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"StormweaverFrameSmallNormal":[["group-background_168_172_BC7",6]],"TacticianFrameSmallAllocated":[["group-background_168_172_BC7",4]],"TacticianFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"TacticianFrameSmallNormal":[["group-background_168_172_BC7",6]],"TitanFrameSmallAllocated":[["group-background_168_172_BC7",4]],"TitanFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"TitanFrameSmallNormal":[["group-background_168_172_BC7",6]],"WarbringerFrameSmallAllocated":[["group-background_168_172_BC7",4]],"WarbringerFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"WarbringerFrameSmallNormal":[["group-background_168_172_BC7",6]],"WitchhunterFrameSmallAllocated":[["group-background_168_172_BC7",4]],"WitchhunterFrameSmallCanAllocate":[["group-background_168_172_BC7",5]],"WitchhunterFrameSmallNormal":[["group-background_168_172_BC7",6]],"Abyssal LichFrameLargeAllocated":[["group-background_216_216_BC7",1]],"Abyssal LichFrameLargeCanAllocate":[["group-background_216_216_BC7",2]],"Abyssal LichFrameLargeNormal":[["group-background_216_216_BC7",3]],"Acolyte of ChayulaFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Acolyte of ChayulaFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Acolyte of ChayulaFrameLargeNormal":[["group-background_216_216_BC7",6]],"AmazonFrameLargeAllocated":[["group-background_216_216_BC7",4]],"AmazonFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"AmazonFrameLargeNormal":[["group-background_216_216_BC7",6]],"Blood MageFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Blood MageFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Blood MageFrameLargeNormal":[["group-background_216_216_BC7",6]],"ChronomancerFrameLargeAllocated":[["group-background_216_216_BC7",4]],"ChronomancerFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"ChronomancerFrameLargeNormal":[["group-background_216_216_BC7",6]],"DeadeyeFrameLargeAllocated":[["group-background_216_216_BC7",4]],"DeadeyeFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"DeadeyeFrameLargeNormal":[["group-background_216_216_BC7",6]],"Disciple of VarashtaFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Disciple of VarashtaFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Disciple of VarashtaFrameLargeNormal":[["group-background_216_216_BC7",6]],"Gemling LegionnaireFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Gemling LegionnaireFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Gemling LegionnaireFrameLargeNormal":[["group-background_216_216_BC7",6]],"InfernalistFrameLargeAllocated":[["group-background_216_216_BC7",4]],"InfernalistFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"InfernalistFrameLargeNormal":[["group-background_216_216_BC7",6]],"InvokerFrameLargeAllocated":[["group-background_216_216_BC7",4]],"InvokerFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"InvokerFrameLargeNormal":[["group-background_216_216_BC7",6]],"LichFrameLargeAllocated":[["group-background_216_216_BC7",4]],"LichFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"LichFrameLargeNormal":[["group-background_216_216_BC7",6]],"Martial ArtistFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Martial ArtistFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Martial ArtistFrameLargeNormal":[["group-background_216_216_BC7",6]],"OracleFrameLargeAllocated":[["group-background_216_216_BC7",4]],"OracleFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"OracleFrameLargeNormal":[["group-background_216_216_BC7",6]],"PathfinderFrameLargeAllocated":[["group-background_216_216_BC7",4]],"PathfinderFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"PathfinderFrameLargeNormal":[["group-background_216_216_BC7",6]],"RitualistFrameLargeAllocated":[["group-background_216_216_BC7",4]],"RitualistFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"RitualistFrameLargeNormal":[["group-background_216_216_BC7",6]],"ShamanFrameLargeAllocated":[["group-background_216_216_BC7",4]],"ShamanFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"ShamanFrameLargeNormal":[["group-background_216_216_BC7",6]],"Smith of KitavaFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Smith of KitavaFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Smith of KitavaFrameLargeNormal":[["group-background_216_216_BC7",6]],"Spirit WalkerFrameLargeAllocated":[["group-background_216_216_BC7",4]],"Spirit WalkerFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"Spirit WalkerFrameLargeNormal":[["group-background_216_216_BC7",6]],"StormweaverFrameLargeAllocated":[["group-background_216_216_BC7",4]],"StormweaverFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"StormweaverFrameLargeNormal":[["group-background_216_216_BC7",6]],"TacticianFrameLargeAllocated":[["group-background_216_216_BC7",4]],"TacticianFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"TacticianFrameLargeNormal":[["group-background_216_216_BC7",6]],"TitanFrameLargeAllocated":[["group-background_216_216_BC7",4]],"TitanFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"TitanFrameLargeNormal":[["group-background_216_216_BC7",6]],"WarbringerFrameLargeAllocated":[["group-background_216_216_BC7",4]],"WarbringerFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"WarbringerFrameLargeNormal":[["group-background_216_216_BC7",6]],"WitchhunterFrameLargeAllocated":[["group-background_216_216_BC7",4]],"WitchhunterFrameLargeCanAllocate":[["group-background_216_216_BC7",5]],"WitchhunterFrameLargeNormal":[["group-background_216_216_BC7",6]],"KeystoneFrameAllocated":[["group-background_228_232_BC7",1]],"KeystoneFrameCanAllocate":[["group-background_228_232_BC7",2]],"KeystoneFrameUnallocated":[["group-background_228_232_BC7",3]],"PSGroupBackground1":[["group-background_368_368_BC7",1]],"PSGroupBackgroundSmallBlank":[["group-background_368_368_BC7",1]],"PSGroupBackground2":[["group-background_476_476_BC7",1]],"PSGroupBackgroundMediumBlank":[["group-background_476_476_BC7",1]],"PSStartNodeBackgroundInactive":[["group-background_536_536_BC7",1]],"PSGroupBackground3":[["group-background_748_392_BC7",1]],"PSGroupBackgroundLargeBlank":[["group-background_748_392_BC7",1]],"Against the Darkness":[["jewel-sockets_160_164_BC7",15]],"Controlled Metamorphosis":[["jewel-sockets_160_164_BC7",4]],"Diamond":[["jewel-sockets_160_164_BC7",6]],"Emerald":[["jewel-sockets_160_164_BC7",8]],"Flesh Crucible":[["jewel-sockets_160_164_BC7",18]],"From Nothing":[["jewel-sockets_160_164_BC7",10]],"Heart of the Well":[["jewel-sockets_160_164_BC7",3]],"Heroic Tragedy":[["jewel-sockets_160_164_BC7",9]],"Megalomaniac":[["jewel-sockets_160_164_BC7",5]],"Prism of Belief":[["jewel-sockets_160_164_BC7",12]],"Ruby":[["jewel-sockets_160_164_BC7",11]],"Sapphire":[["jewel-sockets_160_164_BC7",1]],"The Adorned":[["jewel-sockets_160_164_BC7",17]],"Time-Lost Diamond":[["jewel-sockets_160_164_BC7",7]],"Time-Lost Emerald":[["jewel-sockets_160_164_BC7",13]],"Time-Lost Ruby":[["jewel-sockets_160_164_BC7",14]],"Time-Lost Sapphire":[["jewel-sockets_160_164_BC7",16]],"Timeless Jewel":[["jewel-sockets_160_164_BC7",15]],"Undying Hate":[["jewel-sockets_160_164_BC7",2]],"Voices":[["jewel-sockets_160_164_BC7",5]],"art/textures/interface/2d/2dart/uiimages/ingame/abyss/abysspassiveskillscreenjewelcircle1.dds":[["legion_1032_1032_BC7",1]],"Art/2DArt/SkillIcons/passives/AbyssDexNotable.dds":[["legion_128_128_BC1",1]],"Art/2DArt/SkillIcons/passives/AbyssIntNotable.dds":[["legion_128_128_BC1",2]],"Art/2DArt/SkillIcons/passives/AbyssStrNotable.dds":[["legion_128_128_BC1",3]],"Art/2DArt/SkillIcons/passives/AmanamusDefiance.dds":[["legion_128_128_BC1",4]],"Art/2DArt/SkillIcons/passives/CorruptedDefences.dds":[["legion_128_128_BC1",5]],"Art/2DArt/SkillIcons/passives/DevotionNotable.dds":[["legion_128_128_BC1",6]],"Art/2DArt/SkillIcons/passives/DivineFlesh.dds":[["legion_128_128_BC1",7]],"Art/2DArt/SkillIcons/passives/EternalEmpireDefensiveNotable.dds":[["legion_128_128_BC1",8]],"Art/2DArt/SkillIcons/passives/EternalEmpireOffensiveNotable.dds":[["legion_128_128_BC1",9]],"Art/2DArt/SkillIcons/passives/EternalYouth.dds":[["legion_128_128_BC1",10],["skills-disabled_128_128_BC1",106],["skills_128_128_BC1",106]],"Art/2DArt/SkillIcons/passives/FocusedRage.dds":[["legion_128_128_BC1",11]],"Art/2DArt/SkillIcons/passives/GlancingBlows.dds":[["legion_128_128_BC1",12],["skills-disabled_128_128_BC1",123],["skills_128_128_BC1",123]],"Art/2DArt/SkillIcons/passives/InnerConviction.dds":[["legion_128_128_BC1",13]],"Art/2DArt/SkillIcons/passives/KalguuranDexKeystone.dds":[["legion_128_128_BC1",14]],"Art/2DArt/SkillIcons/passives/KalguuranDexNotable.dds":[["legion_128_128_BC1",15]],"Art/2DArt/SkillIcons/passives/KalguuranIntKeystone.dds":[["legion_128_128_BC1",16]],"Art/2DArt/SkillIcons/passives/KalguuranIntNotable.dds":[["legion_128_128_BC1",17]],"Art/2DArt/SkillIcons/passives/KalguuranStrKeystone.dds":[["legion_128_128_BC1",18]],"Art/2DArt/SkillIcons/passives/KalguuranStrNotable.dds":[["legion_128_128_BC1",19]],"Art/2DArt/SkillIcons/passives/KulemaksSovereignty.dds":[["legion_128_128_BC1",20]],"Art/2DArt/SkillIcons/passives/KurgasAmbition.dds":[["legion_128_128_BC1",21]],"Art/2DArt/SkillIcons/passives/MiracleMaker.dds":[["legion_128_128_BC1",22],["skills-disabled_128_128_BC1",189],["skills_128_128_BC1",189]],"Art/2DArt/SkillIcons/passives/OasisKeystone.dds":[["legion_128_128_BC1",23]],"Art/2DArt/SkillIcons/passives/PowerOfPurpose.dds":[["legion_128_128_BC1",24]],"Art/2DArt/SkillIcons/passives/SharpandBrittle.dds":[["legion_128_128_BC1",25]],"Art/2DArt/SkillIcons/passives/SoulTetherKeystone.dds":[["legion_128_128_BC1",26]],"Art/2DArt/SkillIcons/passives/StrengthOfBlood.dds":[["legion_128_128_BC1",27]],"Art/2DArt/SkillIcons/passives/SupremeDecadence.dds":[["legion_128_128_BC1",28]],"Art/2DArt/SkillIcons/passives/SupremeEgo.dds":[["legion_128_128_BC1",29]],"Art/2DArt/SkillIcons/passives/SupremeGrandstand.dds":[["legion_128_128_BC1",30]],"Art/2DArt/SkillIcons/passives/SupremeProdigy.dds":[["legion_128_128_BC1",31]],"Art/2DArt/SkillIcons/passives/TecrodsBrutality.dds":[["legion_128_128_BC1",32]],"Art/2DArt/SkillIcons/passives/TemperedByWar.dds":[["legion_128_128_BC1",33]],"Art/2DArt/SkillIcons/passives/TheBlindMonk.dds":[["legion_128_128_BC1",34]],"Art/2DArt/SkillIcons/passives/TranscendenceKeystone.dds":[["legion_128_128_BC1",35]],"Art/2DArt/SkillIcons/passives/UlamansVision.dds":[["legion_128_128_BC1",36]],"Art/2DArt/SkillIcons/passives/VaalNotableDefensive.dds":[["legion_128_128_BC1",37]],"Art/2DArt/SkillIcons/passives/VaalNotableOffensive.dds":[["legion_128_128_BC1",38]],"Art/2DArt/SkillIcons/passives/WindDancer.dds":[["legion_128_128_BC1",39]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreeneternalempirejewelcircle1.dds":[["legion_572_572_BC7",1]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreeneternalempirejewelcircle2.dds":[["legion_572_572_BC7",2]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenkalguuranjewelcircle1.dds":[["legion_572_572_BC7",3]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenkalguuranjewelcircle2.dds":[["legion_572_572_BC7",4]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenkaruijewelcircle1.dds":[["legion_572_572_BC7",5]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenkaruijewelcircle2.dds":[["legion_572_572_BC7",6]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenmarakethjewelcircle1.dds":[["legion_572_572_BC7",7]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenmarakethjewelcircle2.dds":[["legion_572_572_BC7",8]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreentemplarjewelcircle1.dds":[["legion_572_572_BC7",9]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreentemplarjewelcircle2.dds":[["legion_572_572_BC7",10]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenvaaljewelcircle1.dds":[["legion_572_572_BC7",11]],"art/textures/interface/2d/2dart/uiimages/ingame/passiveskillscreenvaaljewelcircle2.dds":[["legion_572_572_BC7",12]],"Art/2DArt/SkillIcons/passives/AbyssJewelNode.dds":[["legion_64_64_BC1",1]],"Art/2DArt/SkillIcons/passives/DevotionNode.dds":[["legion_64_64_BC1",2]],"Art/2DArt/SkillIcons/passives/EternalEmpireBlank.dds":[["legion_64_64_BC1",3]],"Art/2DArt/SkillIcons/passives/VaalDefensive.dds":[["legion_64_64_BC1",4]],"Art/2DArt/SkillIcons/passives/VaalOffensive.dds":[["legion_64_64_BC1",5]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryAccuracyPattern":[["mastery-active-effect_784_776_BC7",1]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryArmourAndEnergyShieldPattern":[["mastery-active-effect_784_776_BC7",2]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryArmourAndEvasionPattern":[["mastery-active-effect_784_776_BC7",3]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryArmourPattern":[["mastery-active-effect_784_776_BC7",4]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryAttackPattern":[["mastery-active-effect_784_776_BC7",5]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryAttributesPattern":[["mastery-active-effect_784_776_BC7",6]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryAxePattern":[["mastery-active-effect_784_776_BC7",7]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBannerPattern":[["mastery-active-effect_784_776_BC7",8]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBleedingPattern":[["mastery-active-effect_784_776_BC7",9]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBlindPattern":[["mastery-active-effect_784_776_BC7",10]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBlockPattern":[["mastery-active-effect_784_776_BC7",11]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBowPattern":[["mastery-active-effect_784_776_BC7",12]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBrandPattern":[["mastery-active-effect_784_776_BC7",13]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryBucklersPattern":[["mastery-active-effect_784_776_BC7",14]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryCasterPattern":[["mastery-active-effect_784_776_BC7",15]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryChaosPattern":[["mastery-active-effect_784_776_BC7",16]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryChargesPattern":[["mastery-active-effect_784_776_BC7",17]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryCharmsPattern":[["mastery-active-effect_784_776_BC7",18]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryColdPattern":[["mastery-active-effect_784_776_BC7",19]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryCompanionsPattern":[["mastery-active-effect_784_776_BC7",20]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryCriticalsPattern":[["mastery-active-effect_784_776_BC7",21]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryCursePattern":[["mastery-active-effect_784_776_BC7",22]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryDamageOverTimePattern":[["mastery-active-effect_784_776_BC7",23]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryDualWieldPattern":[["mastery-active-effect_784_776_BC7",24]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryDurationPattern":[["mastery-active-effect_784_776_BC7",25]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryElementalPattern":[["mastery-active-effect_784_776_BC7",26]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryEnergyPattern":[["mastery-active-effect_784_776_BC7",27]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryEvasionAndEnergyShieldPattern":[["mastery-active-effect_784_776_BC7",28]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryEvasionPattern":[["mastery-active-effect_784_776_BC7",29]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryFirePattern":[["mastery-active-effect_784_776_BC7",30]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryFlaskPattern":[["mastery-active-effect_784_776_BC7",31]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryFortifyPattern":[["mastery-active-effect_784_776_BC7",32]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryImpalePattern":[["mastery-active-effect_784_776_BC7",33]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryInstillationsPattern":[["mastery-active-effect_784_776_BC7",34]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryLeechPattern":[["mastery-active-effect_784_776_BC7",35]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryLifePattern":[["mastery-active-effect_784_776_BC7",36]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryLightningPattern":[["mastery-active-effect_784_776_BC7",37]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryLinkPattern":[["mastery-active-effect_784_776_BC7",38]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryMacePattern":[["mastery-active-effect_784_776_BC7",39]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryManaPattern":[["mastery-active-effect_784_776_BC7",40]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryMarkPattern":[["mastery-active-effect_784_776_BC7",41]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryMinionDefencePattern":[["mastery-active-effect_784_776_BC7",42]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryMinionOffencePattern":[["mastery-active-effect_784_776_BC7",43]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryPhysicalPattern":[["mastery-active-effect_784_776_BC7",44]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryPoisonPattern":[["mastery-active-effect_784_776_BC7",45]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryProjectilePattern":[["mastery-active-effect_784_776_BC7",46]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryRecoveryPattern":[["mastery-active-effect_784_776_BC7",47]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryReservationPattern":[["mastery-active-effect_784_776_BC7",48]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryResistancesAndAilmentProtectionPattern":[["mastery-active-effect_784_776_BC7",49]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryShieldPattern":[["mastery-active-effect_784_776_BC7",50]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasterySpearsPattern":[["mastery-active-effect_784_776_BC7",51]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasterySpellSuppressionPattern":[["mastery-active-effect_784_776_BC7",52]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryStaffPattern":[["mastery-active-effect_784_776_BC7",53]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryStunPattern":[["mastery-active-effect_784_776_BC7",54]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasterySwordPattern":[["mastery-active-effect_784_776_BC7",55]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryThornsPattern":[["mastery-active-effect_784_776_BC7",56]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryTotemPattern":[["mastery-active-effect_784_776_BC7",57]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryTrapsPattern":[["mastery-active-effect_784_776_BC7",58]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryTwoHandsPattern":[["mastery-active-effect_784_776_BC7",59]],"Art/2DArt/UIImages/InGame/PassiveMastery/MasteryBackgroundGraphic/MasteryWarcryPattern":[["mastery-active-effect_784_776_BC7",60]],"Contempt":[["oils_108_108_RGBA",11]],"Despair":[["oils_108_108_RGBA",7]],"Disgust":[["oils_108_108_RGBA",8]],"Envy":[["oils_108_108_RGBA",9]],"Fear":[["oils_108_108_RGBA",1]],"Ferocity":[["oils_108_108_RGBA",12]],"Greed":[["oils_108_108_RGBA",4]],"Guilt":[["oils_108_108_RGBA",5]],"Ire":[["oils_108_108_RGBA",6]],"Isolation":[["oils_108_108_RGBA",2]],"Melancholy":[["oils_108_108_RGBA",13]],"Paranoia":[["oils_108_108_RGBA",10]],"Suffering":[["oils_108_108_RGBA",3]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaBreachFlameDoubles.dds":[["skills-disabled_128_128_BC1",1],["skills_128_128_BC1",1]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaBreachWalk.dds":[["skills-disabled_128_128_BC1",2],["skills_128_128_BC1",2]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaDarknessProtectsLonger.dds":[["skills-disabled_128_128_BC1",3],["skills_128_128_BC1",3]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaExtraChaosDamage.dds":[["skills-disabled_128_128_BC1",4],["skills_128_128_BC1",4]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaExtraChaosDamageBlue.dds":[["skills-disabled_128_128_BC1",5],["skills_128_128_BC1",5]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaExtraChaosDamagePerDarkness.dds":[["skills-disabled_128_128_BC1",6],["skills_128_128_BC1",6]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaExtraChaosDamageRed.dds":[["skills-disabled_128_128_BC1",7],["skills_128_128_BC1",7]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaExtraChaosResistance.dds":[["skills-disabled_128_128_BC1",8],["skills_128_128_BC1",8]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaFlameSelector.dds":[["skills-disabled_128_128_BC1",9],["skills_128_128_BC1",9]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaManaLeechInstant.dds":[["skills-disabled_128_128_BC1",10],["skills_128_128_BC1",10]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaReplaceSpiritWithDarkness.dds":[["skills-disabled_128_128_BC1",11],["skills_128_128_BC1",11]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaSpecialNode.dds":[["skills-disabled_128_128_BC1",12],["skills_128_128_BC1",12]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaUnravelling.dds":[["skills-disabled_128_128_BC1",13],["skills_128_128_BC1",13]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonConsumeFrenzyChargeGainElementalInstillation.dds":[["skills-disabled_128_128_BC1",14],["skills_128_128_BC1",14]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonDoubleEvasionfromGlovesBootsHelmsHalvedBodyArmour.dds":[["skills-disabled_128_128_BC1",15],["skills_128_128_BC1",15]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonElementalDamageReductionperElementalInstillation.dds":[["skills-disabled_128_128_BC1",16],["skills_128_128_BC1",16]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonExcessChancetoHitConvertedtoCritHitChance.dds":[["skills-disabled_128_128_BC1",17],["skills_128_128_BC1",17]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonGainPhysicalDamageWeaponsAccuracy.dds":[["skills-disabled_128_128_BC1",18],["skills_128_128_BC1",18]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonIncreasedLifeRecoveryRatePerMissingLife.dds":[["skills-disabled_128_128_BC1",19],["skills_128_128_BC1",19]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonLifeFlasksRecoverManaViceVersa.dds":[["skills-disabled_128_128_BC1",20],["skills_128_128_BC1",20]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonRareUniqueBloodlusted.dds":[["skills-disabled_128_128_BC1",21],["skills_128_128_BC1",21]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonSpeedBloodlustedEnemy.dds":[["skills-disabled_128_128_BC1",22],["skills_128_128_BC1",22]],"Art/2DArt/SkillIcons/passives/Annihilation.dds":[["skills-disabled_128_128_BC1",23],["skills_128_128_BC1",23]],"Art/2DArt/SkillIcons/passives/ArchonGenericNotable.dds":[["skills-disabled_128_128_BC1",24],["skills_128_128_BC1",24]],"Art/2DArt/SkillIcons/passives/ArchonofUndeathNoteble.dds":[["skills-disabled_128_128_BC1",25],["skills_128_128_BC1",25]],"Art/2DArt/SkillIcons/passives/ArmourElementalDamageDeflect.dds":[["skills-disabled_128_128_BC1",26],["skills_128_128_BC1",26]],"Art/2DArt/SkillIcons/passives/ArmourElementalDamageEnergyShieldRecharge.dds":[["skills-disabled_128_128_BC1",27],["skills_128_128_BC1",27]],"Art/2DArt/SkillIcons/passives/AspectOfTheLynx.dds":[["skills-disabled_128_128_BC1",28],["skills_128_128_BC1",28]],"Art/2DArt/SkillIcons/passives/AuraNotable.dds":[["skills-disabled_128_128_BC1",29],["skills_128_128_BC1",29]],"Art/2DArt/SkillIcons/passives/AzmeriPrimalMonkeyNotable.dds":[["skills-disabled_128_128_BC1",30],["skills_128_128_BC1",30]],"Art/2DArt/SkillIcons/passives/AzmeriPrimalOwlNotable.dds":[["skills-disabled_128_128_BC1",31],["skills_128_128_BC1",31]],"Art/2DArt/SkillIcons/passives/AzmeriPrimalSnakeNotable.dds":[["skills-disabled_128_128_BC1",32],["skills_128_128_BC1",32]],"Art/2DArt/SkillIcons/passives/AzmeriSacredFoxNotable.dds":[["skills-disabled_128_128_BC1",33],["skills_128_128_BC1",33]],"Art/2DArt/SkillIcons/passives/AzmeriSacredRabbitNotable.dds":[["skills-disabled_128_128_BC1",34],["skills_128_128_BC1",34]],"Art/2DArt/SkillIcons/passives/AzmeriVividCatNotable.dds":[["skills-disabled_128_128_BC1",35],["skills_128_128_BC1",35]],"Art/2DArt/SkillIcons/passives/AzmeriVividStagNotable.dds":[["skills-disabled_128_128_BC1",36],["skills_128_128_BC1",36]],"Art/2DArt/SkillIcons/passives/AzmeriVividWolfNotable.dds":[["skills-disabled_128_128_BC1",37],["skills_128_128_BC1",37]],"Art/2DArt/SkillIcons/passives/AzmeriWildBearNotable.dds":[["skills-disabled_128_128_BC1",38],["skills_128_128_BC1",38]],"Art/2DArt/SkillIcons/passives/AzmeriWildBoarNotable.dds":[["skills-disabled_128_128_BC1",39],["skills_128_128_BC1",39]],"Art/2DArt/SkillIcons/passives/AzmeriWildOxNotable.dds":[["skills-disabled_128_128_BC1",40],["skills_128_128_BC1",40]],"Art/2DArt/SkillIcons/passives/BannerAreaNotable.dds":[["skills-disabled_128_128_BC1",41],["skills_128_128_BC1",41]],"Art/2DArt/SkillIcons/passives/BattleRouse.dds":[["skills-disabled_128_128_BC1",42],["skills_128_128_BC1",42]],"Art/2DArt/SkillIcons/passives/Blood2.dds":[["skills-disabled_128_128_BC1",43],["skills_128_128_BC1",43]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageCritDamagePerLife.dds":[["skills-disabled_128_128_BC1",44],["skills_128_128_BC1",44]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageCurseInfiniteDuration.dds":[["skills-disabled_128_128_BC1",45],["skills_128_128_BC1",45]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageDamageLeechedLife.dds":[["skills-disabled_128_128_BC1",46],["skills_128_128_BC1",46]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageGainLifeEnergyShield.dds":[["skills-disabled_128_128_BC1",47],["skills_128_128_BC1",47]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageHigherSpellBaseCritStrike.dds":[["skills-disabled_128_128_BC1",48],["skills_128_128_BC1",48]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageLeaveBloodOrbs.dds":[["skills-disabled_128_128_BC1",49],["skills_128_128_BC1",49]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageLifeLoss.dds":[["skills-disabled_128_128_BC1",50],["skills_128_128_BC1",50]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageSaguineTides.dds":[["skills-disabled_128_128_BC1",51],["skills_128_128_BC1",51]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodPhysicalDamageExtraGore.dds":[["skills-disabled_128_128_BC1",52],["skills_128_128_BC1",52]],"Art/2DArt/SkillIcons/passives/BowDamage.dds":[["skills-disabled_128_128_BC1",53],["skills_128_128_BC1",53]],"Art/2DArt/SkillIcons/passives/BucklersNotable1.dds":[["skills-disabled_128_128_BC1",54],["skills_128_128_BC1",54]],"Art/2DArt/SkillIcons/passives/BulwarkKeystone.dds":[["skills-disabled_128_128_BC1",55],["skills_128_128_BC1",55]],"Art/2DArt/SkillIcons/passives/ChainingProjectiles.dds":[["skills-disabled_128_128_BC1",56],["skills_128_128_BC1",56]],"Art/2DArt/SkillIcons/passives/ChannellingAttacksNotable2.dds":[["skills-disabled_128_128_BC1",57],["skills_128_128_BC1",57]],"Art/2DArt/SkillIcons/passives/ChaosDamage2.dds":[["skills-disabled_128_128_BC1",58],["skills_128_128_BC1",58]],"Art/2DArt/SkillIcons/passives/CharmNotable1.dds":[["skills-disabled_128_128_BC1",59],["skills_128_128_BC1",59]],"Art/2DArt/SkillIcons/passives/ClawsOfTheMagpie.dds":[["skills-disabled_128_128_BC1",60],["skills_128_128_BC1",60]],"Art/2DArt/SkillIcons/passives/ColdAndFireHybridNotable.dds":[["skills-disabled_128_128_BC1",61],["skills_128_128_BC1",61]],"Art/2DArt/SkillIcons/passives/CompanionsNotable1.dds":[["skills-disabled_128_128_BC1",62],["skills_128_128_BC1",62]],"Art/2DArt/SkillIcons/passives/CrimsonAssaultKeystone.dds":[["skills-disabled_128_128_BC1",63],["skills_128_128_BC1",63]],"Art/2DArt/SkillIcons/passives/CriticalStrikesNotable.dds":[["skills-disabled_128_128_BC1",64],["skills_128_128_BC1",64]],"Art/2DArt/SkillIcons/passives/CursemitigationclusterNotable.dds":[["skills-disabled_128_128_BC1",65],["skills_128_128_BC1",65]],"Art/2DArt/SkillIcons/passives/DancewithDeathKeystone.dds":[["skills-disabled_128_128_BC1",66],["skills_128_128_BC1",66]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeDealMoreProjectileDamageClose.dds":[["skills-disabled_128_128_BC1",67],["skills_128_128_BC1",67]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeDealMoreProjectileDamageFarAway.dds":[["skills-disabled_128_128_BC1",68],["skills_128_128_BC1",68]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeFrenzyChargesGeneration.dds":[["skills-disabled_128_128_BC1",69],["skills_128_128_BC1",69]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeFrenzyChargesHaveMoreEffect.dds":[["skills-disabled_128_128_BC1",70],["skills_128_128_BC1",70]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeGrantsTwoAdditionalProjectiles.dds":[["skills-disabled_128_128_BC1",71],["skills_128_128_BC1",71]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeLingeringMirage.dds":[["skills-disabled_128_128_BC1",72],["skills_128_128_BC1",72]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeMarkEnemiesSpread.dds":[["skills-disabled_128_128_BC1",73],["skills_128_128_BC1",73]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeMoreAccuracy.dds":[["skills-disabled_128_128_BC1",74],["skills_128_128_BC1",74]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeProjectileDamageChoose.dds":[["skills-disabled_128_128_BC1",75],["skills_128_128_BC1",75]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeTailwind.dds":[["skills-disabled_128_128_BC1",76],["skills_128_128_BC1",76]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/ElementalDamageTakenFromMana.dds":[["skills-disabled_128_128_BC1",77],["skills_128_128_BC1",77]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/EnergyShieldPhyDmgReduction.dds":[["skills-disabled_128_128_BC1",78],["skills_128_128_BC1",78]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/FireDjinnEmberSlash.dds":[["skills-disabled_128_128_BC1",79],["skills_128_128_BC1",79]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/FireDjinnFlameRunes.dds":[["skills-disabled_128_128_BC1",80],["skills_128_128_BC1",80]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/FireDjinnMeteoricSlam.dds":[["skills-disabled_128_128_BC1",81],["skills_128_128_BC1",81]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/FocusStaff.dds":[["skills-disabled_128_128_BC1",82],["skills_128_128_BC1",82]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/MoreEnergyShieldRechargeRate.dds":[["skills-disabled_128_128_BC1",83],["skills_128_128_BC1",83]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/SandDjinnCorpseBeetles.dds":[["skills-disabled_128_128_BC1",84],["skills_128_128_BC1",84]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/SandDjinnDaggerslamSkill.dds":[["skills-disabled_128_128_BC1",85],["skills_128_128_BC1",85]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/SandDjinnExplosiveTeleport.dds":[["skills-disabled_128_128_BC1",86],["skills_128_128_BC1",86]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/SummonFireDjinn.dds":[["skills-disabled_128_128_BC1",87],["skills_128_128_BC1",87]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/SummonSandDjinn.dds":[["skills-disabled_128_128_BC1",88],["skills_128_128_BC1",88]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/SummonWaterDjinn.dds":[["skills-disabled_128_128_BC1",89],["skills_128_128_BC1",89]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/TimelostJewelsLargerRadius.dds":[["skills-disabled_128_128_BC1",90],["skills_128_128_BC1",90]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/WaterDjinnChiilldedGroundBurst.dds":[["skills-disabled_128_128_BC1",91],["skills_128_128_BC1",91]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/WaterDjinnCommandOasis.dds":[["skills-disabled_128_128_BC1",92],["skills_128_128_BC1",92]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/WaterDjinnESRechargeCommand.dds":[["skills-disabled_128_128_BC1",93],["skills_128_128_BC1",93]],"Art/2DArt/SkillIcons/passives/DruidAlternateEnergyShield.dds":[["skills-disabled_128_128_BC1",94],["skills_128_128_BC1",94]],"Art/2DArt/SkillIcons/passives/DruidAnimism.dds":[["skills-disabled_128_128_BC1",95],["skills_128_128_BC1",95]],"Art/2DArt/SkillIcons/passives/DruidGenericShapeshiftNotable.dds":[["skills-disabled_128_128_BC1",96],["skills_128_128_BC1",96]],"Art/2DArt/SkillIcons/passives/DruidRageKeystone.dds":[["skills-disabled_128_128_BC1",97],["skills_128_128_BC1",97]],"Art/2DArt/SkillIcons/passives/DruidShapeshiftBearNotable.dds":[["skills-disabled_128_128_BC1",98],["skills_128_128_BC1",98]],"Art/2DArt/SkillIcons/passives/DruidShapeshiftWolfNotable.dds":[["skills-disabled_128_128_BC1",99],["skills_128_128_BC1",99]],"Art/2DArt/SkillIcons/passives/DruidShapeshiftWyvernNotable.dds":[["skills-disabled_128_128_BC1",100],["skills_128_128_BC1",100]],"Art/2DArt/SkillIcons/passives/DruidWildsurgeIncantation.dds":[["skills-disabled_128_128_BC1",101],["skills_128_128_BC1",101]],"Art/2DArt/SkillIcons/passives/ElementalDamagewithAttacks2.dds":[["skills-disabled_128_128_BC1",102],["skills_128_128_BC1",102]],"Art/2DArt/SkillIcons/passives/ElementalDominion2.dds":[["skills-disabled_128_128_BC1",103],["skills_128_128_BC1",103]],"Art/2DArt/SkillIcons/passives/ElementalResistance2.dds":[["skills-disabled_128_128_BC1",104],["skills_128_128_BC1",104]],"Art/2DArt/SkillIcons/passives/EnergyShieldRechargeDeflect.dds":[["skills-disabled_128_128_BC1",105],["skills_128_128_BC1",105]],"Art/2DArt/SkillIcons/passives/EvasionAndBlindNotable.dds":[["skills-disabled_128_128_BC1",107],["skills_128_128_BC1",107]],"Art/2DArt/SkillIcons/passives/FireSpellsBecomeChaosSpellsKeystone.dds":[["skills-disabled_128_128_BC1",108],["skills_128_128_BC1",108]],"Art/2DArt/SkillIcons/passives/FlaskNotableCritStrikeRecharge.dds":[["skills-disabled_128_128_BC1",109],["skills_128_128_BC1",109]],"Art/2DArt/SkillIcons/passives/FlaskNotableFlasksLastLonger.dds":[["skills-disabled_128_128_BC1",110],["skills_128_128_BC1",110]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingBarrier.dds":[["skills-disabled_128_128_BC1",111],["skills_128_128_BC1",111]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingBuffSkillsReserveLessSpirit.dds":[["skills-disabled_128_128_BC1",112],["skills_128_128_BC1",112]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingHighestAttributeSatisfiesGemRequirements.dds":[["skills-disabled_128_128_BC1",113],["skills_128_128_BC1",113]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingInherentBonusesFromAttributesDouble.dds":[["skills-disabled_128_128_BC1",114],["skills_128_128_BC1",114]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingLevelAllSkillGems.dds":[["skills-disabled_128_128_BC1",115],["skills_128_128_BC1",115]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingLevelDexSkillGems.dds":[["skills-disabled_128_128_BC1",116],["skills_128_128_BC1",116]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingLevelIntSkillGems.dds":[["skills-disabled_128_128_BC1",117],["skills_128_128_BC1",117]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingLevelStrSkillGems.dds":[["skills-disabled_128_128_BC1",118],["skills_128_128_BC1",118]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingMaxElementalResistanceSupportColour.dds":[["skills-disabled_128_128_BC1",119],["skills_128_128_BC1",119]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingSameSupportMultipleTimes.dds":[["skills-disabled_128_128_BC1",120],["skills_128_128_BC1",120]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingSkillsAdditionalSupport.dds":[["skills-disabled_128_128_BC1",121],["skills_128_128_BC1",121]],"Art/2DArt/SkillIcons/passives/GiantBloodKeystone.dds":[["skills-disabled_128_128_BC1",122],["skills_128_128_BC1",122]],"Art/2DArt/SkillIcons/passives/Harrier.dds":[["skills-disabled_128_128_BC1",124],["skills_128_128_BC1",124]],"Art/2DArt/SkillIcons/passives/HeartstopperKeystone.dds":[["skills-disabled_128_128_BC1",125],["skills_128_128_BC1",125]],"Art/2DArt/SkillIcons/passives/Hearty.dds":[["skills-disabled_128_128_BC1",126],["skills_128_128_BC1",126]],"Art/2DArt/SkillIcons/passives/HiredKiller2.dds":[["skills-disabled_128_128_BC1",127],["skills_128_128_BC1",127]],"Art/2DArt/SkillIcons/passives/HollowPalmTechniqueKeystone.dds":[["skills-disabled_128_128_BC1",128],["skills_128_128_BC1",128]],"Art/2DArt/SkillIcons/passives/Hunter.dds":[["skills-disabled_128_128_BC1",129],["skills_128_128_BC1",129]],"Art/2DArt/SkillIcons/passives/IncreasedAttackDamageNotable.dds":[["skills-disabled_128_128_BC1",130],["skills_128_128_BC1",130]],"Art/2DArt/SkillIcons/passives/IncreasedChaosDamage.dds":[["skills-disabled_128_128_BC1",131],["skills_128_128_BC1",131]],"Art/2DArt/SkillIcons/passives/IncreasedManaCostNotable.dds":[["skills-disabled_128_128_BC1",132],["skills_128_128_BC1",132]],"Art/2DArt/SkillIcons/passives/IncreasedMaximumLifeNotable.dds":[["skills-disabled_128_128_BC1",133],["skills_128_128_BC1",133]],"Art/2DArt/SkillIcons/passives/IncreasedPhysicalDamage.dds":[["skills-disabled_128_128_BC1",134],["skills_128_128_BC1",134]],"Art/2DArt/SkillIcons/passives/Infernalist/FuryManifest.dds":[["skills-disabled_128_128_BC1",135],["skills_128_128_BC1",135]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalFamiliar.dds":[["skills-disabled_128_128_BC1",136],["skills_128_128_BC1",136]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistConvertLifeToEnergyShield.dds":[["skills-disabled_128_128_BC1",137],["skills_128_128_BC1",137]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistConvertLifeToMana.dds":[["skills-disabled_128_128_BC1",138],["skills_128_128_BC1",138]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistConvertLifeToSpirit.dds":[["skills-disabled_128_128_BC1",139],["skills_128_128_BC1",139]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistInfernalHeat.dds":[["skills-disabled_128_128_BC1",140],["skills_128_128_BC1",140]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistTransformIntoDemon1.dds":[["skills-disabled_128_128_BC1",141],["skills_128_128_BC1",141]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistTransformIntoDemon2.dds":[["skills-disabled_128_128_BC1",142],["skills_128_128_BC1",142]],"Art/2DArt/SkillIcons/passives/Infernalist/MoltenFury.dds":[["skills-disabled_128_128_BC1",143],["skills_128_128_BC1",143]],"Art/2DArt/SkillIcons/passives/Infernalist/ScorchTheEarth.dds":[["skills-disabled_128_128_BC1",144],["skills_128_128_BC1",144]],"Art/2DArt/SkillIcons/passives/InstillationsNotable1.dds":[["skills-disabled_128_128_BC1",145],["skills_128_128_BC1",145]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerChillChanceBasedOnDamage.dds":[["skills-disabled_128_128_BC1",146],["skills_128_128_BC1",146]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerCriticalStrikesIgnoreResistances.dds":[["skills-disabled_128_128_BC1",147],["skills_128_128_BC1",147]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerEnergyDoubled.dds":[["skills-disabled_128_128_BC1",148],["skills_128_128_BC1",148]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerEvasionEnergyShieldGrantsSpirit.dds":[["skills-disabled_128_128_BC1",149],["skills_128_128_BC1",149]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerEvasionGrantsPhysicalDamageReduction.dds":[["skills-disabled_128_128_BC1",150],["skills_128_128_BC1",150]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerGrantsMeditate.dds":[["skills-disabled_128_128_BC1",151],["skills_128_128_BC1",151]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerShockMagnitude.dds":[["skills-disabled_128_128_BC1",152],["skills_128_128_BC1",152]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerUnboundAvatar.dds":[["skills-disabled_128_128_BC1",153],["skills_128_128_BC1",153]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerWildStrike.dds":[["skills-disabled_128_128_BC1",154],["skills_128_128_BC1",154]],"Art/2DArt/SkillIcons/passives/KeystoneAvatarOfFire.dds":[["skills-disabled_128_128_BC1",155],["skills_128_128_BC1",155]],"Art/2DArt/SkillIcons/passives/KeystoneBloodMagic.dds":[["skills-disabled_128_128_BC1",156],["skills_128_128_BC1",156]],"Art/2DArt/SkillIcons/passives/KeystoneChaosInoculation.dds":[["skills-disabled_128_128_BC1",157],["skills_128_128_BC1",157]],"Art/2DArt/SkillIcons/passives/KeystoneConduit.dds":[["skills-disabled_128_128_BC1",158],["skills_128_128_BC1",158]],"Art/2DArt/SkillIcons/passives/KeystoneEldritchBattery.dds":[["skills-disabled_128_128_BC1",159],["skills_128_128_BC1",159]],"Art/2DArt/SkillIcons/passives/KeystoneElementalEquilibrium.dds":[["skills-disabled_128_128_BC1",160],["skills_128_128_BC1",160]],"Art/2DArt/SkillIcons/passives/KeystoneIronReflexes.dds":[["skills-disabled_128_128_BC1",161],["skills_128_128_BC1",161]],"Art/2DArt/SkillIcons/passives/KeystonePainAttunement.dds":[["skills-disabled_128_128_BC1",162],["skills_128_128_BC1",162]],"Art/2DArt/SkillIcons/passives/KeystoneResoluteTechnique.dds":[["skills-disabled_128_128_BC1",163],["skills_128_128_BC1",163]],"Art/2DArt/SkillIcons/passives/KeystoneUnwaveringStance.dds":[["skills-disabled_128_128_BC1",164],["skills_128_128_BC1",164]],"Art/2DArt/SkillIcons/passives/KeystoneWhispersOfDoom.dds":[["skills-disabled_128_128_BC1",165],["skills_128_128_BC1",165]],"Art/2DArt/SkillIcons/passives/LethalAssault.dds":[["skills-disabled_128_128_BC1",166],["skills_128_128_BC1",166]],"Art/2DArt/SkillIcons/passives/Lich/AbyssalLichAbyssalApparition.dds":[["skills-disabled_128_128_BC1",167],["skills_128_128_BC1",167]],"Art/2DArt/SkillIcons/passives/Lich/AbyssalLichBoneGraft.dds":[["skills-disabled_128_128_BC1",168],["skills_128_128_BC1",168]],"Art/2DArt/SkillIcons/passives/Lich/AbyssalLichBoneOffering.dds":[["skills-disabled_128_128_BC1",169],["skills_128_128_BC1",169]],"Art/2DArt/SkillIcons/passives/Lich/LichApplyAdditionalCurses.dds":[["skills-disabled_128_128_BC1",170],["skills_128_128_BC1",170]],"Art/2DArt/SkillIcons/passives/Lich/LichCursedEnemiesExplodeChaos.dds":[["skills-disabled_128_128_BC1",171],["skills_128_128_BC1",171]],"Art/2DArt/SkillIcons/passives/Lich/LichImprovedUnholyMight.dds":[["skills-disabled_128_128_BC1",172],["skills_128_128_BC1",172]],"Art/2DArt/SkillIcons/passives/Lich/LichLifeCannotChangeWhileES.dds":[["skills-disabled_128_128_BC1",173],["skills_128_128_BC1",173]],"Art/2DArt/SkillIcons/passives/Lich/LichManaRegenBasedOnMaxLife.dds":[["skills-disabled_128_128_BC1",174],["skills_128_128_BC1",174]],"Art/2DArt/SkillIcons/passives/Lich/LichSpellCostESandMoreDMG.dds":[["skills-disabled_128_128_BC1",175],["skills_128_128_BC1",175]],"Art/2DArt/SkillIcons/passives/Lich/LichSpellsConsumePowerCharges.dds":[["skills-disabled_128_128_BC1",176],["skills_128_128_BC1",176]],"Art/2DArt/SkillIcons/passives/Lich/LichUnholyMight.dds":[["skills-disabled_128_128_BC1",177],["skills_128_128_BC1",177]],"Art/2DArt/SkillIcons/passives/LifeandMana.dds":[["skills-disabled_128_128_BC1",178],["skills_128_128_BC1",178]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistAdditionalComboHit.dds":[["skills-disabled_128_128_BC1",179],["skills_128_128_BC1",179]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistAllAttacksGenerateCombo.dds":[["skills-disabled_128_128_BC1",180],["skills_128_128_BC1",180]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistCarrySoectralBell.dds":[["skills-disabled_128_128_BC1",181],["skills_128_128_BC1",181]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistCoveredinStone.dds":[["skills-disabled_128_128_BC1",182],["skills_128_128_BC1",182]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistExtraRunes.dds":[["skills-disabled_128_128_BC1",183],["skills_128_128_BC1",183]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistHandWraps.dds":[["skills-disabled_128_128_BC1",184],["skills_128_128_BC1",184]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistMantraofIllusions.dds":[["skills-disabled_128_128_BC1",185],["skills_128_128_BC1",185]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistSpectralBell.dds":[["skills-disabled_128_128_BC1",186],["skills_128_128_BC1",186]],"Art/2DArt/SkillIcons/passives/Meleerange.dds":[["skills-disabled_128_128_BC1",187],["skills_128_128_BC1",187]],"Art/2DArt/SkillIcons/passives/MineManaReservationNotable.dds":[["skills-disabled_128_128_BC1",188],["skills_128_128_BC1",188]],"Art/2DArt/SkillIcons/passives/MonkAccuracyChakra.dds":[["skills-disabled_128_128_BC1",190],["skills_128_128_BC1",190]],"Art/2DArt/SkillIcons/passives/MonkElementalChakra.dds":[["skills-disabled_128_128_BC1",191],["skills_128_128_BC1",191]],"Art/2DArt/SkillIcons/passives/MonkEnergyShieldChakra.dds":[["skills-disabled_128_128_BC1",192],["skills_128_128_BC1",192]],"Art/2DArt/SkillIcons/passives/MonkHealthChakra.dds":[["skills-disabled_128_128_BC1",193],["skills_128_128_BC1",193]],"Art/2DArt/SkillIcons/passives/MonkManaChakra.dds":[["skills-disabled_128_128_BC1",194],["skills_128_128_BC1",194]],"Art/2DArt/SkillIcons/passives/MonkStrengthChakra.dds":[["skills-disabled_128_128_BC1",195],["skills_128_128_BC1",195]],"Art/2DArt/SkillIcons/passives/MonkStunChakra.dds":[["skills-disabled_128_128_BC1",196],["skills_128_128_BC1",196]],"Art/2DArt/SkillIcons/passives/MovementSpeedandEvasion.dds":[["skills-disabled_128_128_BC1",197],["skills_128_128_BC1",197]],"Art/2DArt/SkillIcons/passives/MultipleBeastCompanionsKeystone.dds":[["skills-disabled_128_128_BC1",198],["skills_128_128_BC1",198]],"Art/2DArt/SkillIcons/passives/NecromanticTalismanKeystone.dds":[["skills-disabled_128_128_BC1",199],["skills_128_128_BC1",199]],"Art/2DArt/SkillIcons/passives/OasisKeystone2.dds":[["skills-disabled_128_128_BC1",200],["skills_128_128_BC1",200]],"Art/2DArt/SkillIcons/passives/Oracle/OracleDiffChoices.dds":[["skills-disabled_128_128_BC1",201],["skills_128_128_BC1",201]],"Art/2DArt/SkillIcons/passives/Oracle/OracleEnemiesActionsUnlucky.dds":[["skills-disabled_128_128_BC1",202],["skills_128_128_BC1",202]],"Art/2DArt/SkillIcons/passives/Oracle/OracleLifeManaHits.dds":[["skills-disabled_128_128_BC1",203],["skills_128_128_BC1",203]],"Art/2DArt/SkillIcons/passives/Oracle/OraclePassiveTreeAllocation.dds":[["skills-disabled_128_128_BC1",204],["skills_128_128_BC1",204]],"Art/2DArt/SkillIcons/passives/Oracle/OracleRerollingCrit.dds":[["skills-disabled_128_128_BC1",205],["skills_128_128_BC1",205]],"Art/2DArt/SkillIcons/passives/Oracle/OracleRipFromTime.dds":[["skills-disabled_128_128_BC1",206],["skills_128_128_BC1",206]],"Art/2DArt/SkillIcons/passives/Oracle/OracleSpellFlux.dds":[["skills-disabled_128_128_BC1",207],["skills_128_128_BC1",207]],"Art/2DArt/SkillIcons/passives/Oracle/OracleTotemLimit.dds":[["skills-disabled_128_128_BC1",208],["skills_128_128_BC1",208]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderAdditionalPoints.dds":[["skills-disabled_128_128_BC1",209],["skills_128_128_BC1",209]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderBrewConcoction.dds":[["skills-disabled_128_128_BC1",210],["skills_128_128_BC1",210]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderBrewConcoctionBleed.dds":[["skills-disabled_128_128_BC1",211],["skills_128_128_BC1",211]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderBrewConcoctionCold.dds":[["skills-disabled_128_128_BC1",212],["skills_128_128_BC1",212]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderBrewConcoctionFire.dds":[["skills-disabled_128_128_BC1",213],["skills_128_128_BC1",213]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderBrewConcoctionLightning.dds":[["skills-disabled_128_128_BC1",214],["skills_128_128_BC1",214]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderBrewConcoctionPoison.dds":[["skills-disabled_128_128_BC1",215],["skills_128_128_BC1",215]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderCannotBeSlowed.dds":[["skills-disabled_128_128_BC1",216],["skills_128_128_BC1",216]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderEnemiesMultiplePoisons.dds":[["skills-disabled_128_128_BC1",217],["skills_128_128_BC1",217]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderEvasionDmgReducVsElementalDmg.dds":[["skills-disabled_128_128_BC1",218],["skills_128_128_BC1",218]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderLifeFlasks.dds":[["skills-disabled_128_128_BC1",219],["skills_128_128_BC1",219]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderMoreMovemenSpeedUsingSkills.dds":[["skills-disabled_128_128_BC1",220],["skills_128_128_BC1",220]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderMultichoicePath.dds":[["skills-disabled_128_128_BC1",221],["skills_128_128_BC1",221]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderPathoftheSorceress.dds":[["skills-disabled_128_128_BC1",222],["skills_128_128_BC1",222]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderPathoftheWarrior.dds":[["skills-disabled_128_128_BC1",223],["skills_128_128_BC1",223]],"Art/2DArt/SkillIcons/passives/PhysicalDamageOverTimeNotable.dds":[["skills-disabled_128_128_BC1",224],["skills_128_128_BC1",224]],"Art/2DArt/SkillIcons/passives/Poison.dds":[["skills-disabled_128_128_BC1",225],["skills_128_128_BC1",225]],"Art/2DArt/SkillIcons/passives/PressurePoints.dds":[["skills-disabled_128_128_BC1",226],["skills_128_128_BC1",226]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistBloodBoils.dds":[["skills-disabled_128_128_BC1",227],["skills_128_128_BC1",227]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistDrainManaActivateCharms.dds":[["skills-disabled_128_128_BC1",228],["skills_128_128_BC1",228]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistIncreasedEffectOfJewellery.dds":[["skills-disabled_128_128_BC1",229],["skills_128_128_BC1",229]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistLifeLeechFromElementalOrChaos.dds":[["skills-disabled_128_128_BC1",230],["skills_128_128_BC1",230]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistPlusOneMaxCharm.dds":[["skills-disabled_128_128_BC1",231],["skills_128_128_BC1",231]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistPlusOneRingSlot.dds":[["skills-disabled_128_128_BC1",232],["skills_128_128_BC1",232]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistStabCorpse.dds":[["skills-disabled_128_128_BC1",233],["skills_128_128_BC1",233]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistStabCorpseHand.dds":[["skills-disabled_128_128_BC1",234],["skills_128_128_BC1",234]],"Art/2DArt/SkillIcons/passives/ProjectileDmgNotable.dds":[["skills-disabled_128_128_BC1",235],["skills_128_128_BC1",235]],"Art/2DArt/SkillIcons/passives/ProjectilesNotable.dds":[["skills-disabled_128_128_BC1",236],["skills_128_128_BC1",236]],"Art/2DArt/SkillIcons/passives/PuppeteerNoteble.dds":[["skills-disabled_128_128_BC1",237],["skills_128_128_BC1",237]],"Art/2DArt/SkillIcons/passives/RageNotable.dds":[["skills-disabled_128_128_BC1",238],["skills_128_128_BC1",238]],"Art/2DArt/SkillIcons/passives/RemnantNotable.dds":[["skills-disabled_128_128_BC1",239],["skills_128_128_BC1",239]],"Art/2DArt/SkillIcons/passives/ResonanceKeystone.dds":[["skills-disabled_128_128_BC1",240],["skills_128_128_BC1",240]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanAdaptToElements.dds":[["skills-disabled_128_128_BC1",241],["skills_128_128_BC1",241]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanEvenMoreAdaptation.dds":[["skills-disabled_128_128_BC1",242],["skills_128_128_BC1",242]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanGainSpiritEmptyCharmSlot.dds":[["skills-disabled_128_128_BC1",243],["skills_128_128_BC1",243]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanPickEleDmg.dds":[["skills-disabled_128_128_BC1",244],["skills_128_128_BC1",244]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanRageAffectsSpells.dds":[["skills-disabled_128_128_BC1",245],["skills_128_128_BC1",245]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanRageonHit.dds":[["skills-disabled_128_128_BC1",246],["skills_128_128_BC1",246]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanRunesTalismans.dds":[["skills-disabled_128_128_BC1",247],["skills_128_128_BC1",247]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanUnleashTheElements.dds":[["skills-disabled_128_128_BC1",248],["skills_128_128_BC1",248]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaCanOnlyWearNormalRarityBodyArmour.dds":[["skills-disabled_128_128_BC1",249],["skills_128_128_BC1",249]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaCreateMinionMeleeWeapon.dds":[["skills-disabled_128_128_BC1",250],["skills_128_128_BC1",250]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaFireResistAppliesToColdLightning.dds":[["skills-disabled_128_128_BC1",251],["skills_128_128_BC1",251]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaImbueMainHandWeapon.dds":[["skills-disabled_128_128_BC1",252],["skills_128_128_BC1",252]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaImprovedFireResistAppliesToColdLightning.dds":[["skills-disabled_128_128_BC1",253],["skills_128_128_BC1",253]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus1.dds":[["skills-disabled_128_128_BC1",254],["skills_128_128_BC1",254]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus10.dds":[["skills-disabled_128_128_BC1",255],["skills_128_128_BC1",255]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus11.dds":[["skills-disabled_128_128_BC1",256],["skills_128_128_BC1",256]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus12.dds":[["skills-disabled_128_128_BC1",257],["skills_128_128_BC1",257]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus2.dds":[["skills-disabled_128_128_BC1",258],["skills_128_128_BC1",258]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus3.dds":[["skills-disabled_128_128_BC1",259],["skills_128_128_BC1",259]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus4.dds":[["skills-disabled_128_128_BC1",260],["skills_128_128_BC1",260]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus5.dds":[["skills-disabled_128_128_BC1",261],["skills_128_128_BC1",261]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus6.dds":[["skills-disabled_128_128_BC1",262],["skills_128_128_BC1",262]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus7.dds":[["skills-disabled_128_128_BC1",263],["skills_128_128_BC1",263]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus8.dds":[["skills-disabled_128_128_BC1",264],["skills_128_128_BC1",264]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithOfKitavaNormalArmourBonus9.dds":[["skills-disabled_128_128_BC1",265],["skills_128_128_BC1",265]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithofKitavaTriggerFireSpellsMeleeWeapon.dds":[["skills-disabled_128_128_BC1",266],["skills_128_128_BC1",266]],"Art/2DArt/SkillIcons/passives/SorceressInvocationSpellsKeystone.dds":[["skills-disabled_128_128_BC1",267],["skills_128_128_BC1",267]],"Art/2DArt/SkillIcons/passives/SpearsNotable1.dds":[["skills-disabled_128_128_BC1",268],["skills_128_128_BC1",268]],"Art/2DArt/SkillIcons/passives/SpellMultiplyer2.dds":[["skills-disabled_128_128_BC1",269],["skills_128_128_BC1",269]],"Art/2DArt/SkillIcons/passives/SpellSupressionNotable1.dds":[["skills-disabled_128_128_BC1",270],["skills_128_128_BC1",270]],"Art/2DArt/SkillIcons/passives/Storm Weaver.dds":[["skills-disabled_128_128_BC1",271],["skills_128_128_BC1",271]],"Art/2DArt/SkillIcons/passives/Stormweaver/AllDamageCanChill.dds":[["skills-disabled_128_128_BC1",272],["skills_128_128_BC1",272]],"Art/2DArt/SkillIcons/passives/Stormweaver/AllDamageCanShock.dds":[["skills-disabled_128_128_BC1",273],["skills_128_128_BC1",273]],"Art/2DArt/SkillIcons/passives/Stormweaver/ChillAddditionalTime.dds":[["skills-disabled_128_128_BC1",274],["skills_128_128_BC1",274]],"Art/2DArt/SkillIcons/passives/Stormweaver/GrantsArcaneSurge.dds":[["skills-disabled_128_128_BC1",275],["skills_128_128_BC1",275]],"Art/2DArt/SkillIcons/passives/Stormweaver/GrantsElementalStorm.dds":[["skills-disabled_128_128_BC1",276],["skills_128_128_BC1",276]],"Art/2DArt/SkillIcons/passives/Stormweaver/ImprovedArcaneSurge.dds":[["skills-disabled_128_128_BC1",277],["skills_128_128_BC1",277]],"Art/2DArt/SkillIcons/passives/Stormweaver/ImprovedElementalStorm.dds":[["skills-disabled_128_128_BC1",278],["skills_128_128_BC1",278]],"Art/2DArt/SkillIcons/passives/Stormweaver/ShockAddditionalTime.dds":[["skills-disabled_128_128_BC1",279],["skills_128_128_BC1",279]],"Art/2DArt/SkillIcons/passives/Stormweaver/StormweaverRemnant1.dds":[["skills-disabled_128_128_BC1",280],["skills_128_128_BC1",280]],"Art/2DArt/SkillIcons/passives/Stormweaver/StormweaverRemnant2.dds":[["skills-disabled_128_128_BC1",281],["skills_128_128_BC1",281]],"Art/2DArt/SkillIcons/passives/StunAvoidNotable.dds":[["skills-disabled_128_128_BC1",282],["skills_128_128_BC1",282]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianAlliesGainAttack.dds":[["skills-disabled_128_128_BC1",283],["skills_128_128_BC1",283]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianDeathFromAboveCommand.dds":[["skills-disabled_128_128_BC1",284],["skills_128_128_BC1",284]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianEvasionArmourHigher.dds":[["skills-disabled_128_128_BC1",285],["skills_128_128_BC1",285]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianGainStunThresholdArmour.dds":[["skills-disabled_128_128_BC1",286],["skills_128_128_BC1",286]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianLessSpiritCostBuff.dds":[["skills-disabled_128_128_BC1",287],["skills_128_128_BC1",287]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianMultipleBanners.dds":[["skills-disabled_128_128_BC1",288],["skills_128_128_BC1",288]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianPinnedEnemiesCannotAct.dds":[["skills-disabled_128_128_BC1",289],["skills_128_128_BC1",289]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianProjectileBuildsPin.dds":[["skills-disabled_128_128_BC1",290],["skills_128_128_BC1",290]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianTotemAura.dds":[["skills-disabled_128_128_BC1",291],["skills_128_128_BC1",291]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianTotems.dds":[["skills-disabled_128_128_BC1",292],["skills_128_128_BC1",292]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistChanceSkillNoCooldownSkill.dds":[["skills-disabled_128_128_BC1",293],["skills_128_128_BC1",293]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistFasterRecoup.dds":[["skills-disabled_128_128_BC1",294],["skills_128_128_BC1",294]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistGainMoreCastSpeed8Seconds.dds":[["skills-disabled_128_128_BC1",295],["skills_128_128_BC1",295]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistGrantsReloadCooldownsSkill.dds":[["skills-disabled_128_128_BC1",296],["skills_128_128_BC1",296]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistGrantsTemporalRiftSkill.dds":[["skills-disabled_128_128_BC1",297],["skills_128_128_BC1",297]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistGrantsTimeStopSkill.dds":[["skills-disabled_128_128_BC1",298],["skills_128_128_BC1",298]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistNearbyEnemiesProjectilesSlowed.dds":[["skills-disabled_128_128_BC1",299],["skills_128_128_BC1",299]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistSynchronisationofPain.dds":[["skills-disabled_128_128_BC1",300],["skills_128_128_BC1",300]],"Art/2DArt/SkillIcons/passives/ThornsNotable1.dds":[["skills-disabled_128_128_BC1",301],["skills_128_128_BC1",301]],"Art/2DArt/SkillIcons/passives/Titan/TitanAdditionalInventory.dds":[["skills-disabled_128_128_BC1",302],["skills_128_128_BC1",302]],"Art/2DArt/SkillIcons/passives/Titan/TitanMoreBodyArmour.dds":[["skills-disabled_128_128_BC1",303],["skills_128_128_BC1",303]],"Art/2DArt/SkillIcons/passives/Titan/TitanMoreMaxLife.dds":[["skills-disabled_128_128_BC1",304],["skills_128_128_BC1",304]],"Art/2DArt/SkillIcons/passives/Titan/TitanMountainSplitter.dds":[["skills-disabled_128_128_BC1",305],["skills_128_128_BC1",305]],"Art/2DArt/SkillIcons/passives/Titan/TitanSlamSkillsAftershock.dds":[["skills-disabled_128_128_BC1",306],["skills_128_128_BC1",306]],"Art/2DArt/SkillIcons/passives/Titan/TitanSlamSkillsFistOfWar.dds":[["skills-disabled_128_128_BC1",307],["skills_128_128_BC1",307]],"Art/2DArt/SkillIcons/passives/Titan/TitanSmallPassiveDoubled.dds":[["skills-disabled_128_128_BC1",308],["skills_128_128_BC1",308]],"Art/2DArt/SkillIcons/passives/Titan/TitanYourHitsCrushEnemies.dds":[["skills-disabled_128_128_BC1",309],["skills_128_128_BC1",309]],"Art/2DArt/SkillIcons/passives/Trap.dds":[["skills-disabled_128_128_BC1",310],["skills_128_128_BC1",310]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerBlockChance.dds":[["skills-disabled_128_128_BC1",311],["skills_128_128_BC1",311]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerBreakEnemyArmour.dds":[["skills-disabled_128_128_BC1",312],["skills_128_128_BC1",312]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerCanBlockAllDamageShieldNotRaised.dds":[["skills-disabled_128_128_BC1",313],["skills_128_128_BC1",313]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerDamageTakenByTotems.dds":[["skills-disabled_128_128_BC1",314],["skills_128_128_BC1",314]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerEncasedInJade.dds":[["skills-disabled_128_128_BC1",315],["skills_128_128_BC1",315]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerEnemyArmourBrokenBelowZero.dds":[["skills-disabled_128_128_BC1",316],["skills_128_128_BC1",316]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerTotemsDefendedByAncestors.dds":[["skills-disabled_128_128_BC1",317],["skills_128_128_BC1",317]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerWarcryExplodesCorpses.dds":[["skills-disabled_128_128_BC1",318],["skills_128_128_BC1",318]],"Art/2DArt/SkillIcons/passives/Warrior.dds":[["skills-disabled_128_128_BC1",319],["skills_128_128_BC1",319]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerBonusPerSocketedTalisman.dds":[["skills-disabled_128_128_BC1",320],["skills_128_128_BC1",320]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerCompanionDmgWeapon.dds":[["skills-disabled_128_128_BC1",321],["skills_128_128_BC1",321]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerOwlFeatherHigherCritDmg.dds":[["skills-disabled_128_128_BC1",322],["skills_128_128_BC1",322]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerOwlFeathers.dds":[["skills-disabled_128_128_BC1",323],["skills_128_128_BC1",323]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerSacredWisp.dds":[["skills-disabled_128_128_BC1",324],["skills_128_128_BC1",324]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerTameBeastTargetUnique.dds":[["skills-disabled_128_128_BC1",325],["skills_128_128_BC1",325]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerVividStags.dds":[["skills-disabled_128_128_BC1",326],["skills_128_128_BC1",326]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerVividWisps.dds":[["skills-disabled_128_128_BC1",327],["skills_128_128_BC1",327]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerWildBear.dds":[["skills-disabled_128_128_BC1",328],["skills_128_128_BC1",328]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterArmourEvasionConvertedSpellAegis.dds":[["skills-disabled_128_128_BC1",329],["skills_128_128_BC1",329]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterCullingStrike.dds":[["skills-disabled_128_128_BC1",330],["skills_128_128_BC1",330]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterDamageMonsterMissingFocus.dds":[["skills-disabled_128_128_BC1",331],["skills_128_128_BC1",331]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterDrainMonsterFocus.dds":[["skills-disabled_128_128_BC1",332],["skills_128_128_BC1",332]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterMonsterHolyExplosion.dds":[["skills-disabled_128_128_BC1",333],["skills_128_128_BC1",333]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterRemovePercentageFullLifeEnemies.dds":[["skills-disabled_128_128_BC1",334],["skills_128_128_BC1",334]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterSpecPoints.dds":[["skills-disabled_128_128_BC1",335],["skills_128_128_BC1",335]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterStrongerSpellAegis.dds":[["skills-disabled_128_128_BC1",336],["skills_128_128_BC1",336]],"Art/2DArt/SkillIcons/passives/ashfrostandstorm.dds":[["skills-disabled_128_128_BC1",337],["skills_128_128_BC1",337]],"Art/2DArt/SkillIcons/passives/bodysoul.dds":[["skills-disabled_128_128_BC1",338],["skills_128_128_BC1",338]],"Art/2DArt/SkillIcons/passives/deepwisdom.dds":[["skills-disabled_128_128_BC1",339],["skills_128_128_BC1",339]],"Art/2DArt/SkillIcons/passives/eagleeye.dds":[["skills-disabled_128_128_BC1",340],["skills_128_128_BC1",340]],"Art/2DArt/SkillIcons/passives/executioner.dds":[["skills-disabled_128_128_BC1",341],["skills_128_128_BC1",341]],"Art/2DArt/SkillIcons/passives/finesse.dds":[["skills-disabled_128_128_BC1",342],["skills_128_128_BC1",342]],"Art/2DArt/SkillIcons/passives/flameborn.dds":[["skills-disabled_128_128_BC1",343],["skills_128_128_BC1",343]],"Art/2DArt/SkillIcons/passives/frostborn.dds":[["skills-disabled_128_128_BC1",344],["skills_128_128_BC1",344]],"Art/2DArt/SkillIcons/passives/heroicspirit.dds":[["skills-disabled_128_128_BC1",345],["skills_128_128_BC1",345]],"Art/2DArt/SkillIcons/passives/legstrength.dds":[["skills-disabled_128_128_BC1",346],["skills_128_128_BC1",346]],"Art/2DArt/SkillIcons/passives/lifeleech.dds":[["skills-disabled_128_128_BC1",347],["skills_128_128_BC1",347]],"Art/2DArt/SkillIcons/passives/liferegentoenergyshield.dds":[["skills-disabled_128_128_BC1",348],["skills_128_128_BC1",348]],"Art/2DArt/SkillIcons/passives/newnewattackspeed.dds":[["skills-disabled_128_128_BC1",349],["skills_128_128_BC1",349]],"Art/2DArt/SkillIcons/passives/steelspan.dds":[["skills-disabled_128_128_BC1",350],["skills_128_128_BC1",350]],"Art/2DArt/SkillIcons/passives/stormborn.dds":[["skills-disabled_128_128_BC1",351],["skills_128_128_BC1",351]],"Art/2DArt/SkillIcons/passives/strongarm.dds":[["skills-disabled_128_128_BC1",352],["skills_128_128_BC1",352]],"Art/2DArt/SkillIcons/passives/totemmax.dds":[["skills-disabled_128_128_BC1",353],["skills_128_128_BC1",353]],"Art/2DArt/SkillIcons/passives/vaalpact.dds":[["skills-disabled_128_128_BC1",354],["skills_128_128_BC1",354]],"Art/2DArt/SkillIcons/passives/MasteryBlank.dds":[["skills-disabled_172_172_BC1",1],["skills_172_172_BC1",1]],"Art/2DArt/SkillIcons/passives/Infernalist/Fireblood.dds":[["skills-disabled_176_176_BC1",1],["skills_176_176_BC1",1]],"Art/2DArt/SkillIcons/ExplosiveGrenade.dds":[["skills-disabled_64_64_BC1",1],["skills_64_64_BC1",1]],"Art/2DArt/SkillIcons/WitchBoneStorm.dds":[["skills-disabled_64_64_BC1",2],["skills_64_64_BC1",2]],"Art/2DArt/SkillIcons/icongroundslam.dds":[["skills-disabled_64_64_BC1",3],["skills_64_64_BC1",3]],"Art/2DArt/SkillIcons/passives/2handeddamage.dds":[["skills-disabled_64_64_BC1",4],["skills_64_64_BC1",4]],"Art/2DArt/SkillIcons/passives/AcolyteofChayula/AcolyteOfChayulaNode.dds":[["skills-disabled_64_64_BC1",5],["skills_64_64_BC1",5]],"Art/2DArt/SkillIcons/passives/Amazon/AmazonNode.dds":[["skills-disabled_64_64_BC1",6],["skills_64_64_BC1",6]],"Art/2DArt/SkillIcons/passives/ArchonGeneric.dds":[["skills-disabled_64_64_BC1",7],["skills_64_64_BC1",7]],"Art/2DArt/SkillIcons/passives/ArchonofUndeathNode.dds":[["skills-disabled_64_64_BC1",8],["skills_64_64_BC1",8]],"Art/2DArt/SkillIcons/passives/AreaDmgNode.dds":[["skills-disabled_64_64_BC1",9],["skills_64_64_BC1",9]],"Art/2DArt/SkillIcons/passives/ArmourAndEnergyShieldNode.dds":[["skills-disabled_64_64_BC1",10],["skills_64_64_BC1",10]],"Art/2DArt/SkillIcons/passives/ArmourAndEvasionNode.dds":[["skills-disabled_64_64_BC1",11],["skills_64_64_BC1",11]],"Art/2DArt/SkillIcons/passives/ArmourBreak1BuffIcon.dds":[["skills-disabled_64_64_BC1",12],["skills_64_64_BC1",12]],"Art/2DArt/SkillIcons/passives/ArmourBreak2BuffIcon.dds":[["skills-disabled_64_64_BC1",13],["skills_64_64_BC1",13]],"Art/2DArt/SkillIcons/passives/Ascendants/SkillPoint.dds":[["skills-disabled_64_64_BC1",14],["skills_64_64_BC1",14]],"Art/2DArt/SkillIcons/passives/AzmeriPrimalMonkey.dds":[["skills-disabled_64_64_BC1",15],["skills_64_64_BC1",15]],"Art/2DArt/SkillIcons/passives/AzmeriPrimalOwl.dds":[["skills-disabled_64_64_BC1",16],["skills_64_64_BC1",16]],"Art/2DArt/SkillIcons/passives/AzmeriPrimalSnake.dds":[["skills-disabled_64_64_BC1",17],["skills_64_64_BC1",17]],"Art/2DArt/SkillIcons/passives/AzmeriSacredFox.dds":[["skills-disabled_64_64_BC1",18],["skills_64_64_BC1",18]],"Art/2DArt/SkillIcons/passives/AzmeriSacredRabbit.dds":[["skills-disabled_64_64_BC1",19],["skills_64_64_BC1",19]],"Art/2DArt/SkillIcons/passives/AzmeriVividCat.dds":[["skills-disabled_64_64_BC1",20],["skills_64_64_BC1",20]],"Art/2DArt/SkillIcons/passives/AzmeriVividStag.dds":[["skills-disabled_64_64_BC1",21],["skills_64_64_BC1",21]],"Art/2DArt/SkillIcons/passives/AzmeriVividWolf.dds":[["skills-disabled_64_64_BC1",22],["skills_64_64_BC1",22]],"Art/2DArt/SkillIcons/passives/AzmeriWildBear.dds":[["skills-disabled_64_64_BC1",23],["skills_64_64_BC1",23]],"Art/2DArt/SkillIcons/passives/AzmeriWildBoar.dds":[["skills-disabled_64_64_BC1",24],["skills_64_64_BC1",24]],"Art/2DArt/SkillIcons/passives/AzmeriWildOx.dds":[["skills-disabled_64_64_BC1",25],["skills_64_64_BC1",25]],"Art/2DArt/SkillIcons/passives/BannerResourceAreaNode.dds":[["skills-disabled_64_64_BC1",26],["skills_64_64_BC1",26]],"Art/2DArt/SkillIcons/passives/Bloodmage/BloodMageNode.dds":[["skills-disabled_64_64_BC1",27],["skills_64_64_BC1",27]],"Art/2DArt/SkillIcons/passives/BucklerNode1.dds":[["skills-disabled_64_64_BC1",28],["skills_64_64_BC1",28]],"Art/2DArt/SkillIcons/passives/ChannellingAttacksNode.dds":[["skills-disabled_64_64_BC1",29],["skills_64_64_BC1",29]],"Art/2DArt/SkillIcons/passives/ChannellingDamage.dds":[["skills-disabled_64_64_BC1",30],["skills_64_64_BC1",30]],"Art/2DArt/SkillIcons/passives/ChannellingSpeed.dds":[["skills-disabled_64_64_BC1",31],["skills_64_64_BC1",31]],"Art/2DArt/SkillIcons/passives/ChaosDamage.dds":[["skills-disabled_64_64_BC1",32],["skills_64_64_BC1",32]],"Art/2DArt/SkillIcons/passives/ChaosDamagenode.dds":[["skills-disabled_64_64_BC1",33],["skills_64_64_BC1",33]],"Art/2DArt/SkillIcons/passives/CharmNode1.dds":[["skills-disabled_64_64_BC1",34],["skills_64_64_BC1",34]],"Art/2DArt/SkillIcons/passives/ColdDamagenode.dds":[["skills-disabled_64_64_BC1",35],["skills_64_64_BC1",35]],"Art/2DArt/SkillIcons/passives/ColdFireNode.dds":[["skills-disabled_64_64_BC1",36],["skills_64_64_BC1",36]],"Art/2DArt/SkillIcons/passives/ColdLightningNode.dds":[["skills-disabled_64_64_BC1",37],["skills_64_64_BC1",37]],"Art/2DArt/SkillIcons/passives/ColdResistNode.dds":[["skills-disabled_64_64_BC1",38],["skills_64_64_BC1",38]],"Art/2DArt/SkillIcons/passives/CompanionsNode1.dds":[["skills-disabled_64_64_BC1",39],["skills_64_64_BC1",39]],"Art/2DArt/SkillIcons/passives/CorpseDamage.dds":[["skills-disabled_64_64_BC1",40],["skills_64_64_BC1",40]],"Art/2DArt/SkillIcons/passives/CurseEffectNode.dds":[["skills-disabled_64_64_BC1",41],["skills_64_64_BC1",41]],"Art/2DArt/SkillIcons/passives/CursemitigationclusterNode.dds":[["skills-disabled_64_64_BC1",42],["skills_64_64_BC1",42]],"Art/2DArt/SkillIcons/passives/DeadEye/DeadeyeNode.dds":[["skills-disabled_64_64_BC1",43],["skills_64_64_BC1",43]],"Art/2DArt/SkillIcons/passives/DiscipleoftheDjinn/DjinnNode.dds":[["skills-disabled_64_64_BC1",44],["skills_64_64_BC1",44]],"Art/2DArt/SkillIcons/passives/DruidGenericShapeshiftNode.dds":[["skills-disabled_64_64_BC1",45],["skills_64_64_BC1",45]],"Art/2DArt/SkillIcons/passives/DruidShapeshiftBearNode.dds":[["skills-disabled_64_64_BC1",46],["skills_64_64_BC1",46]],"Art/2DArt/SkillIcons/passives/DruidShapeshiftWolfNode.dds":[["skills-disabled_64_64_BC1",47],["skills_64_64_BC1",47]],"Art/2DArt/SkillIcons/passives/DruidShapeshiftWyvernNode.dds":[["skills-disabled_64_64_BC1",48],["skills_64_64_BC1",48]],"Art/2DArt/SkillIcons/passives/ElementalDamagenode.dds":[["skills-disabled_64_64_BC1",49],["skills_64_64_BC1",49]],"Art/2DArt/SkillIcons/passives/EnduranceFrenzyPowerChargeNode.dds":[["skills-disabled_64_64_BC1",50],["skills_64_64_BC1",50]],"Art/2DArt/SkillIcons/passives/EnergyShieldNode.dds":[["skills-disabled_64_64_BC1",51],["skills_64_64_BC1",51]],"Art/2DArt/SkillIcons/passives/EnergyShieldRechargeDeflectNode.dds":[["skills-disabled_64_64_BC1",52],["skills_64_64_BC1",52]],"Art/2DArt/SkillIcons/passives/EvasionNode.dds":[["skills-disabled_64_64_BC1",53],["skills_64_64_BC1",53]],"Art/2DArt/SkillIcons/passives/EvasionandEnergyShieldNode.dds":[["skills-disabled_64_64_BC1",54],["skills_64_64_BC1",54]],"Art/2DArt/SkillIcons/passives/FireDamagenode.dds":[["skills-disabled_64_64_BC1",55],["skills_64_64_BC1",55]],"Art/2DArt/SkillIcons/passives/FireResistNode.dds":[["skills-disabled_64_64_BC1",56],["skills_64_64_BC1",56]],"Art/2DArt/SkillIcons/passives/Gemling/GemlingNode.dds":[["skills-disabled_64_64_BC1",57],["skills_64_64_BC1",57]],"Art/2DArt/SkillIcons/passives/GreenAttackSmallPassive.dds":[["skills-disabled_64_64_BC1",58],["skills_64_64_BC1",58]],"Art/2DArt/SkillIcons/passives/HeraldBuffEffectNode2.dds":[["skills-disabled_64_64_BC1",59],["skills_64_64_BC1",59]],"Art/2DArt/SkillIcons/passives/IncreasedAttackDamageNode.dds":[["skills-disabled_64_64_BC1",60],["skills_64_64_BC1",60]],"Art/2DArt/SkillIcons/passives/IncreasedProjectileSpeedNode.dds":[["skills-disabled_64_64_BC1",61],["skills_64_64_BC1",61]],"Art/2DArt/SkillIcons/passives/Infernalist/InfernalistNode.dds":[["skills-disabled_64_64_BC1",62],["skills_64_64_BC1",62]],"Art/2DArt/SkillIcons/passives/Inquistitor/IncreasedElementalDamageAttackCasteSpeed.dds":[["skills-disabled_64_64_BC1",63],["skills_64_64_BC1",63]],"Art/2DArt/SkillIcons/passives/InstillationsNode1.dds":[["skills-disabled_64_64_BC1",64],["skills_64_64_BC1",64]],"Art/2DArt/SkillIcons/passives/Invoker/InvokerNode.dds":[["skills-disabled_64_64_BC1",65],["skills_64_64_BC1",65]],"Art/2DArt/SkillIcons/passives/Lich/AbyssalLichNode.dds":[["skills-disabled_64_64_BC1",66],["skills_64_64_BC1",66]],"Art/2DArt/SkillIcons/passives/Lich/LichNode.dds":[["skills-disabled_64_64_BC1",67],["skills_64_64_BC1",67]],"Art/2DArt/SkillIcons/passives/LifeRecoupNode.dds":[["skills-disabled_64_64_BC1",68],["skills_64_64_BC1",68]],"Art/2DArt/SkillIcons/passives/LightningDamagenode.dds":[["skills-disabled_64_64_BC1",69],["skills_64_64_BC1",69]],"Art/2DArt/SkillIcons/passives/LightningResistNode.dds":[["skills-disabled_64_64_BC1",70],["skills_64_64_BC1",70]],"Art/2DArt/SkillIcons/passives/ManaLeechThemedNode.dds":[["skills-disabled_64_64_BC1",71],["skills_64_64_BC1",71]],"Art/2DArt/SkillIcons/passives/MarkNode.dds":[["skills-disabled_64_64_BC1",72],["skills_64_64_BC1",72]],"Art/2DArt/SkillIcons/passives/MartialArtist/MartialArtistNode.dds":[["skills-disabled_64_64_BC1",73],["skills_64_64_BC1",73]],"Art/2DArt/SkillIcons/passives/MeleeAoENode.dds":[["skills-disabled_64_64_BC1",74],["skills_64_64_BC1",74]],"Art/2DArt/SkillIcons/passives/MineAreaOfEffectNode.dds":[["skills-disabled_64_64_BC1",75],["skills_64_64_BC1",75]],"Art/2DArt/SkillIcons/passives/MinionAccuracyDamage.dds":[["skills-disabled_64_64_BC1",76],["skills_64_64_BC1",76]],"Art/2DArt/SkillIcons/passives/MinionChaosResistanceNode.dds":[["skills-disabled_64_64_BC1",77],["skills_64_64_BC1",77]],"Art/2DArt/SkillIcons/passives/MinionElementalResistancesNode.dds":[["skills-disabled_64_64_BC1",78],["skills_64_64_BC1",78]],"Art/2DArt/SkillIcons/passives/MinionsandManaNode.dds":[["skills-disabled_64_64_BC1",79],["skills_64_64_BC1",79]],"Art/2DArt/SkillIcons/passives/NodeDualWieldingDamage.dds":[["skills-disabled_64_64_BC1",80],["skills_64_64_BC1",80]],"Art/2DArt/SkillIcons/passives/Oracle/OracleNode.dds":[["skills-disabled_64_64_BC1",81],["skills_64_64_BC1",81]],"Art/2DArt/SkillIcons/passives/PathFinder/PathfinderNode.dds":[["skills-disabled_64_64_BC1",82],["skills_64_64_BC1",82]],"Art/2DArt/SkillIcons/passives/PhysicalDamageChaosNode.dds":[["skills-disabled_64_64_BC1",83],["skills_64_64_BC1",83]],"Art/2DArt/SkillIcons/passives/PhysicalDamageNode.dds":[["skills-disabled_64_64_BC1",84],["skills_64_64_BC1",84]],"Art/2DArt/SkillIcons/passives/PhysicalDamageOverTimeNode.dds":[["skills-disabled_64_64_BC1",85],["skills_64_64_BC1",85]],"Art/2DArt/SkillIcons/passives/Primalist/PrimalistNode.dds":[["skills-disabled_64_64_BC1",86],["skills_64_64_BC1",86]],"Art/2DArt/SkillIcons/passives/ProjectileDmgNode.dds":[["skills-disabled_64_64_BC1",87],["skills_64_64_BC1",87]],"Art/2DArt/SkillIcons/passives/PuppeteerNode.dds":[["skills-disabled_64_64_BC1",88],["skills_64_64_BC1",88]],"Art/2DArt/SkillIcons/passives/Rage.dds":[["skills-disabled_64_64_BC1",89],["skills_64_64_BC1",89]],"Art/2DArt/SkillIcons/passives/RangedTotemDamage.dds":[["skills-disabled_64_64_BC1",90],["skills_64_64_BC1",90]],"Art/2DArt/SkillIcons/passives/ReducedSkillEffectDurationNode.dds":[["skills-disabled_64_64_BC1",91],["skills_64_64_BC1",91]],"Art/2DArt/SkillIcons/passives/Remnant.dds":[["skills-disabled_64_64_BC1",92],["skills_64_64_BC1",92]],"Art/2DArt/SkillIcons/passives/Shaman/ShamanNode.dds":[["skills-disabled_64_64_BC1",93],["skills_64_64_BC1",93]],"Art/2DArt/SkillIcons/passives/ShieldNodeOffensive.dds":[["skills-disabled_64_64_BC1",94],["skills_64_64_BC1",94]],"Art/2DArt/SkillIcons/passives/SkillGemSlotsNode.dds":[["skills-disabled_64_64_BC1",95],["skills_64_64_BC1",95]],"Art/2DArt/SkillIcons/passives/SmithofKitava/SmithofKitavaNode.dds":[["skills-disabled_64_64_BC1",96],["skills_64_64_BC1",96]],"Art/2DArt/SkillIcons/passives/SpearsNode1.dds":[["skills-disabled_64_64_BC1",97],["skills_64_64_BC1",97]],"Art/2DArt/SkillIcons/passives/SpellSuppresionNode.dds":[["skills-disabled_64_64_BC1",98],["skills_64_64_BC1",98]],"Art/2DArt/SkillIcons/passives/Stormweaver/StormweaverNode.dds":[["skills-disabled_64_64_BC1",99],["skills_64_64_BC1",99]],"Art/2DArt/SkillIcons/passives/Tactician/TacticianNode.dds":[["skills-disabled_64_64_BC1",100],["skills_64_64_BC1",100]],"Art/2DArt/SkillIcons/passives/Temporalist/TemporalistNode.dds":[["skills-disabled_64_64_BC1",101],["skills_64_64_BC1",101]],"Art/2DArt/SkillIcons/passives/ThornsNode1.dds":[["skills-disabled_64_64_BC1",102],["skills_64_64_BC1",102]],"Art/2DArt/SkillIcons/passives/Titan/TitanNode.dds":[["skills-disabled_64_64_BC1",103],["skills_64_64_BC1",103]],"Art/2DArt/SkillIcons/passives/WarCryEffect.dds":[["skills-disabled_64_64_BC1",104],["skills_64_64_BC1",104]],"Art/2DArt/SkillIcons/passives/Warbringer/WarbringerNode.dds":[["skills-disabled_64_64_BC1",105],["skills_64_64_BC1",105]],"Art/2DArt/SkillIcons/passives/Wildspeaker/WildspeakerNode.dds":[["skills-disabled_64_64_BC1",106],["skills_64_64_BC1",106]],"Art/2DArt/SkillIcons/passives/Witchhunter/WitchunterNode.dds":[["skills-disabled_64_64_BC1",107],["skills_64_64_BC1",107]],"Art/2DArt/SkillIcons/passives/accuracydex.dds":[["skills-disabled_64_64_BC1",108],["skills_64_64_BC1",108]],"Art/2DArt/SkillIcons/passives/accuracystr.dds":[["skills-disabled_64_64_BC1",109],["skills_64_64_BC1",109]],"Art/2DArt/SkillIcons/passives/areaofeffect.dds":[["skills-disabled_64_64_BC1",110],["skills_64_64_BC1",110]],"Art/2DArt/SkillIcons/passives/attackspeed.dds":[["skills-disabled_64_64_BC1",111],["skills_64_64_BC1",111]],"Art/2DArt/SkillIcons/passives/attackspeedbow.dds":[["skills-disabled_64_64_BC1",112],["skills_64_64_BC1",112]],"Art/2DArt/SkillIcons/passives/auraareaofeffect.dds":[["skills-disabled_64_64_BC1",113],["skills_64_64_BC1",113]],"Art/2DArt/SkillIcons/passives/auraeffect.dds":[["skills-disabled_64_64_BC1",114],["skills_64_64_BC1",114]],"Art/2DArt/SkillIcons/passives/avoidchilling.dds":[["skills-disabled_64_64_BC1",115],["skills_64_64_BC1",115]],"Art/2DArt/SkillIcons/passives/axedmgspeed.dds":[["skills-disabled_64_64_BC1",116],["skills_64_64_BC1",116]],"Art/2DArt/SkillIcons/passives/blankDex.dds":[["skills-disabled_64_64_BC1",117],["skills_64_64_BC1",117]],"Art/2DArt/SkillIcons/passives/blankInt.dds":[["skills-disabled_64_64_BC1",118],["skills_64_64_BC1",118]],"Art/2DArt/SkillIcons/passives/blankStr.dds":[["skills-disabled_64_64_BC1",119],["skills_64_64_BC1",119]],"Art/2DArt/SkillIcons/passives/blockstr.dds":[["skills-disabled_64_64_BC1",120],["skills_64_64_BC1",120]],"Art/2DArt/SkillIcons/passives/castspeed.dds":[["skills-disabled_64_64_BC1",121],["skills_64_64_BC1",121]],"Art/2DArt/SkillIcons/passives/chargedex.dds":[["skills-disabled_64_64_BC1",122],["skills_64_64_BC1",122]],"Art/2DArt/SkillIcons/passives/chargeint.dds":[["skills-disabled_64_64_BC1",123],["skills_64_64_BC1",123]],"Art/2DArt/SkillIcons/passives/chargestr.dds":[["skills-disabled_64_64_BC1",124],["skills_64_64_BC1",124]],"Art/2DArt/SkillIcons/passives/colddamage.dds":[["skills-disabled_64_64_BC1",125],["skills_64_64_BC1",125]],"Art/2DArt/SkillIcons/passives/coldresist.dds":[["skills-disabled_64_64_BC1",126],["skills_64_64_BC1",126]],"Art/2DArt/SkillIcons/passives/criticaldaggerint.dds":[["skills-disabled_64_64_BC1",127],["skills_64_64_BC1",127]],"Art/2DArt/SkillIcons/passives/criticalstrikechance.dds":[["skills-disabled_64_64_BC1",128],["skills_64_64_BC1",128]],"Art/2DArt/SkillIcons/passives/criticalstrikechance2.dds":[["skills-disabled_64_64_BC1",129],["skills_64_64_BC1",129]],"Art/2DArt/SkillIcons/passives/damage.dds":[["skills-disabled_64_64_BC1",130],["skills_64_64_BC1",130]],"Art/2DArt/SkillIcons/passives/damage_blue.dds":[["skills-disabled_64_64_BC1",131],["skills_64_64_BC1",131]],"Art/2DArt/SkillIcons/passives/damageaxe.dds":[["skills-disabled_64_64_BC1",132],["skills_64_64_BC1",132]],"Art/2DArt/SkillIcons/passives/damagedualwield.dds":[["skills-disabled_64_64_BC1",133],["skills_64_64_BC1",133]],"Art/2DArt/SkillIcons/passives/damagespells.dds":[["skills-disabled_64_64_BC1",134],["skills_64_64_BC1",134]],"Art/2DArt/SkillIcons/passives/damagestaff.dds":[["skills-disabled_64_64_BC1",135],["skills_64_64_BC1",135]],"Art/2DArt/SkillIcons/passives/damagesword.dds":[["skills-disabled_64_64_BC1",136],["skills_64_64_BC1",136]],"Art/2DArt/SkillIcons/passives/dmgreduction.dds":[["skills-disabled_64_64_BC1",137],["skills_64_64_BC1",137]],"Art/2DArt/SkillIcons/passives/elementaldamage.dds":[["skills-disabled_64_64_BC1",138],["skills_64_64_BC1",138]],"Art/2DArt/SkillIcons/passives/energyshield.dds":[["skills-disabled_64_64_BC1",139],["skills_64_64_BC1",139]],"Art/2DArt/SkillIcons/passives/evade.dds":[["skills-disabled_64_64_BC1",140],["skills_64_64_BC1",140]],"Art/2DArt/SkillIcons/passives/firedamage.dds":[["skills-disabled_64_64_BC1",141],["skills_64_64_BC1",141]],"Art/2DArt/SkillIcons/passives/firedamageint.dds":[["skills-disabled_64_64_BC1",142],["skills_64_64_BC1",142]],"Art/2DArt/SkillIcons/passives/firedamagestr.dds":[["skills-disabled_64_64_BC1",143],["skills_64_64_BC1",143]],"Art/2DArt/SkillIcons/passives/fireresist.dds":[["skills-disabled_64_64_BC1",144],["skills_64_64_BC1",144]],"Art/2DArt/SkillIcons/passives/flaskdex.dds":[["skills-disabled_64_64_BC1",145],["skills_64_64_BC1",145]],"Art/2DArt/SkillIcons/passives/flaskint.dds":[["skills-disabled_64_64_BC1",146],["skills_64_64_BC1",146]],"Art/2DArt/SkillIcons/passives/flaskstr.dds":[["skills-disabled_64_64_BC1",147],["skills_64_64_BC1",147]],"Art/2DArt/SkillIcons/passives/increasedrunspeeddex.dds":[["skills-disabled_64_64_BC1",148],["skills_64_64_BC1",148]],"Art/2DArt/SkillIcons/passives/knockback.dds":[["skills-disabled_64_64_BC1",149],["skills_64_64_BC1",149]],"Art/2DArt/SkillIcons/passives/life1.dds":[["skills-disabled_64_64_BC1",150],["skills_64_64_BC1",150]],"Art/2DArt/SkillIcons/passives/lifepercentage.dds":[["skills-disabled_64_64_BC1",151],["skills_64_64_BC1",151]],"Art/2DArt/SkillIcons/passives/lightningint.dds":[["skills-disabled_64_64_BC1",152],["skills_64_64_BC1",152]],"Art/2DArt/SkillIcons/passives/lightningstr.dds":[["skills-disabled_64_64_BC1",153],["skills_64_64_BC1",153]],"Art/2DArt/SkillIcons/passives/macedmg.dds":[["skills-disabled_64_64_BC1",154],["skills_64_64_BC1",154]],"Art/2DArt/SkillIcons/passives/mana.dds":[["skills-disabled_64_64_BC1",155],["skills_64_64_BC1",155]],"Art/2DArt/SkillIcons/passives/manaregeneration.dds":[["skills-disabled_64_64_BC1",156],["skills_64_64_BC1",156]],"Art/2DArt/SkillIcons/passives/manastr.dds":[["skills-disabled_64_64_BC1",157],["skills_64_64_BC1",157]],"Art/2DArt/SkillIcons/passives/minionattackspeed.dds":[["skills-disabled_64_64_BC1",158],["skills_64_64_BC1",158]],"Art/2DArt/SkillIcons/passives/miniondamageBlue.dds":[["skills-disabled_64_64_BC1",159],["skills_64_64_BC1",159]],"Art/2DArt/SkillIcons/passives/minionlife.dds":[["skills-disabled_64_64_BC1",160],["skills_64_64_BC1",160]],"Art/2DArt/SkillIcons/passives/minionstr.dds":[["skills-disabled_64_64_BC1",161],["skills_64_64_BC1",161]],"Art/2DArt/SkillIcons/passives/onehanddamage.dds":[["skills-disabled_64_64_BC1",162],["skills_64_64_BC1",162]],"Art/2DArt/SkillIcons/passives/plusattribute.dds":[["skills-disabled_64_64_BC1",163],["skills_64_64_BC1",163]],"Art/2DArt/SkillIcons/passives/plusdexterity.dds":[["skills-disabled_64_64_BC1",164],["skills_64_64_BC1",164]],"Art/2DArt/SkillIcons/passives/plusintelligence.dds":[["skills-disabled_64_64_BC1",165],["skills_64_64_BC1",165]],"Art/2DArt/SkillIcons/passives/plusstrength.dds":[["skills-disabled_64_64_BC1",166],["skills_64_64_BC1",166]],"Art/2DArt/SkillIcons/passives/projectilespeed.dds":[["skills-disabled_64_64_BC1",167],["skills_64_64_BC1",167]],"Art/2DArt/SkillIcons/passives/shieldblock.dds":[["skills-disabled_64_64_BC1",168],["skills_64_64_BC1",168]],"Art/2DArt/SkillIcons/passives/spellcritical.dds":[["skills-disabled_64_64_BC1",169],["skills_64_64_BC1",169]],"Art/2DArt/SkillIcons/passives/stun2h.dds":[["skills-disabled_64_64_BC1",170],["skills_64_64_BC1",170]],"Art/2DArt/SkillIcons/passives/stunstr.dds":[["skills-disabled_64_64_BC1",171],["skills_64_64_BC1",171]],"Art/2DArt/SkillIcons/passives/tempint.dds":[["skills-disabled_64_64_BC1",172],["skills_64_64_BC1",172]],"Art/2DArt/SkillIcons/passives/totemandbrandlife.dds":[["skills-disabled_64_64_BC1",173],["skills_64_64_BC1",173]],"Art/2DArt/SkillIcons/passives/trapdamage.dds":[["skills-disabled_64_64_BC1",174],["skills_64_64_BC1",174]],"Art/2DArt/SkillIcons/passives/trapsmax.dds":[["skills-disabled_64_64_BC1",175],["skills_64_64_BC1",175]]}} \ No newline at end of file diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/1.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/1.webp new file mode 100644 index 0000000000..79c547cfdf Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/1.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/10.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/10.webp new file mode 100644 index 0000000000..f0473432b1 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/10.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/11.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/11.webp new file mode 100644 index 0000000000..87bde9bdbb Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/11.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/12.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/12.webp new file mode 100644 index 0000000000..85a0556c8d Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/12.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/13.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/13.webp new file mode 100644 index 0000000000..5223c6b807 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/13.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/14.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/14.webp new file mode 100644 index 0000000000..33e47450f3 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/14.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/15.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/15.webp new file mode 100644 index 0000000000..2c820157c6 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/15.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/16.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/16.webp new file mode 100644 index 0000000000..ebcb891e00 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/16.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/17.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/17.webp new file mode 100644 index 0000000000..ce191d75c8 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/17.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/18.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/18.webp new file mode 100644 index 0000000000..5f9af9fb35 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/18.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/19.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/19.webp new file mode 100644 index 0000000000..026e56c3a4 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/19.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/2.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/2.webp new file mode 100644 index 0000000000..abe7d73463 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/2.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/20.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/20.webp new file mode 100644 index 0000000000..832ae07681 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/20.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/21.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/21.webp new file mode 100644 index 0000000000..d6204d02fd Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/21.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/22.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/22.webp new file mode 100644 index 0000000000..8e614b1449 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/22.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/23.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/23.webp new file mode 100644 index 0000000000..2fa0b799e5 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/23.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/24.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/24.webp new file mode 100644 index 0000000000..5a633381df Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/24.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/25.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/25.webp new file mode 100644 index 0000000000..e7ef985bea Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/25.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/26.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/26.webp new file mode 100644 index 0000000000..327451d4fc Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/26.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/27.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/27.webp new file mode 100644 index 0000000000..b49a4884a5 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/27.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/28.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/28.webp new file mode 100644 index 0000000000..f19d7365a2 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/28.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/29.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/29.webp new file mode 100644 index 0000000000..4d061b6604 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/29.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/3.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/3.webp new file mode 100644 index 0000000000..fd9d6cf325 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/3.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/30.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/30.webp new file mode 100644 index 0000000000..84610cab69 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/30.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/31.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/31.webp new file mode 100644 index 0000000000..4066e33e3a Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/31.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/32.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/32.webp new file mode 100644 index 0000000000..86b3d50575 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/32.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/33.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/33.webp new file mode 100644 index 0000000000..cc5d89027a Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/33.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/34.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/34.webp new file mode 100644 index 0000000000..02c5cdc5d1 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/34.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/35.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/35.webp new file mode 100644 index 0000000000..0fdf6aa4f3 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/35.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/36.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/36.webp new file mode 100644 index 0000000000..15da47d0be Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/36.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/37.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/37.webp new file mode 100644 index 0000000000..d257ed4100 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/37.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/38.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/38.webp new file mode 100644 index 0000000000..ccc35d58e9 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/38.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/39.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/39.webp new file mode 100644 index 0000000000..617cb441f8 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/39.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/4.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/4.webp new file mode 100644 index 0000000000..942e463fda Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/4.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/40.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/40.webp new file mode 100644 index 0000000000..e7a365b247 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/40.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/41.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/41.webp new file mode 100644 index 0000000000..250607b9ea Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/41.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/42.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/42.webp new file mode 100644 index 0000000000..1888a73815 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/42.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/43.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/43.webp new file mode 100644 index 0000000000..b81c28047d Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/43.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/44.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/44.webp new file mode 100644 index 0000000000..ba749e33ac Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/44.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/45.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/45.webp new file mode 100644 index 0000000000..a78b9e7325 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/45.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/46.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/46.webp new file mode 100644 index 0000000000..bcc8b6b56d Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/46.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/47.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/47.webp new file mode 100644 index 0000000000..98f5ddca82 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/47.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/48.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/48.webp new file mode 100644 index 0000000000..648d30908d Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/48.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/49.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/49.webp new file mode 100644 index 0000000000..2e18b91e12 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/49.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/5.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/5.webp new file mode 100644 index 0000000000..b6024e1a9e Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/5.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/50.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/50.webp new file mode 100644 index 0000000000..34d847fe8a Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/50.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/51.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/51.webp new file mode 100644 index 0000000000..ba1e40b00e Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/51.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/52.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/52.webp new file mode 100644 index 0000000000..2d157337cf Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/52.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/53.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/53.webp new file mode 100644 index 0000000000..8558d7467d Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/53.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/54.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/54.webp new file mode 100644 index 0000000000..a5cd1e927f Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/54.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/55.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/55.webp new file mode 100644 index 0000000000..83a41678ab Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/55.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/56.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/56.webp new file mode 100644 index 0000000000..c000bb27cd Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/56.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/57.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/57.webp new file mode 100644 index 0000000000..c80ae7cb7f Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/57.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/58.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/58.webp new file mode 100644 index 0000000000..6fa8b4d476 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/58.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/59.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/59.webp new file mode 100644 index 0000000000..2861acacfc Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/59.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/6.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/6.webp new file mode 100644 index 0000000000..aa2cb122de Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/6.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/60.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/60.webp new file mode 100644 index 0000000000..145012dea0 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/60.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/7.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/7.webp new file mode 100644 index 0000000000..eb9ed7e249 Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/7.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/8.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/8.webp new file mode 100644 index 0000000000..c92bd1c32e Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/8.webp differ diff --git a/tree-art/0_5/mastery-active-effect_784_776_BC7/9.webp b/tree-art/0_5/mastery-active-effect_784_776_BC7/9.webp new file mode 100644 index 0000000000..dc8a0a958a Binary files /dev/null and b/tree-art/0_5/mastery-active-effect_784_776_BC7/9.webp differ diff --git a/tree-art/0_5/oils_108_108_RGBA.webp b/tree-art/0_5/oils_108_108_RGBA.webp new file mode 100644 index 0000000000..16ffd36b62 Binary files /dev/null and b/tree-art/0_5/oils_108_108_RGBA.webp differ diff --git a/tree-art/0_5/skills-disabled_128_128_BC1.webp b/tree-art/0_5/skills-disabled_128_128_BC1.webp new file mode 100644 index 0000000000..1045a834de Binary files /dev/null and b/tree-art/0_5/skills-disabled_128_128_BC1.webp differ diff --git a/tree-art/0_5/skills-disabled_172_172_BC1.webp b/tree-art/0_5/skills-disabled_172_172_BC1.webp new file mode 100644 index 0000000000..eb6134ef0a Binary files /dev/null and b/tree-art/0_5/skills-disabled_172_172_BC1.webp differ diff --git a/tree-art/0_5/skills-disabled_176_176_BC1.webp b/tree-art/0_5/skills-disabled_176_176_BC1.webp new file mode 100644 index 0000000000..d276e04406 Binary files /dev/null and b/tree-art/0_5/skills-disabled_176_176_BC1.webp differ diff --git a/tree-art/0_5/skills-disabled_64_64_BC1.webp b/tree-art/0_5/skills-disabled_64_64_BC1.webp new file mode 100644 index 0000000000..cf1ab350ca Binary files /dev/null and b/tree-art/0_5/skills-disabled_64_64_BC1.webp differ diff --git a/tree-art/0_5/skills_128_128_BC1.webp b/tree-art/0_5/skills_128_128_BC1.webp new file mode 100644 index 0000000000..1045a834de Binary files /dev/null and b/tree-art/0_5/skills_128_128_BC1.webp differ diff --git a/tree-art/0_5/skills_172_172_BC1.webp b/tree-art/0_5/skills_172_172_BC1.webp new file mode 100644 index 0000000000..eb6134ef0a Binary files /dev/null and b/tree-art/0_5/skills_172_172_BC1.webp differ diff --git a/tree-art/0_5/skills_176_176_BC1.webp b/tree-art/0_5/skills_176_176_BC1.webp new file mode 100644 index 0000000000..d276e04406 Binary files /dev/null and b/tree-art/0_5/skills_176_176_BC1.webp differ diff --git a/tree-art/0_5/skills_64_64_BC1.webp b/tree-art/0_5/skills_64_64_BC1.webp new file mode 100644 index 0000000000..cf1ab350ca Binary files /dev/null and b/tree-art/0_5/skills_64_64_BC1.webp differ diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000000..b947077876 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000..7ae0e830cf --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + PoB2 Enhanced + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000000..2421211523 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1949 @@ +{ + "name": "pob2-enhanced-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pob2-enhanced-web", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.103.3", + "lucide-react": "^1.48.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.1" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.103.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.3.tgz", + "integrity": "sha512-w6gJ5Alt+s55Wuok6/aURtR3AufW3PuNjnM0b3RC1xUz3MVA0EC9j7U3texGftvtOTYWiPVvQzCZjv3Cvzwc/g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.103.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.3.tgz", + "integrity": "sha512-QJMF0QUwJaFm6rrYAq1mlYyMsdiqd9w6gAsFSy+F+8g6/8pQrRy9u3a2yQGpSAmMO4DAZmRHwFZ8UulLaJkBmg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.103.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "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/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "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/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000..2180d6a77c --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "pob2-enhanced-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.103.3", + "lucide-react": "^1.48.0", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.1" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000000..700b40b296 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,81 @@ +import { BookOpen, Bot, Gem, Network, Settings2, Shield, TrendingUp } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { useLiveRevision, useOverview } from "./api/hooks"; +import { ErrorText } from "./components/ErrorText"; +import { Header } from "./components/Header"; +import { BudgetCard } from "./components/BudgetCard"; +import { StatPanel } from "./components/StatPanel"; +import { AiTab } from "./features/ai/AiTab"; +import { ConfigTab } from "./features/config/ConfigTab"; +import { ItemsTab } from "./features/items/ItemsTab"; +import { ReferenceTab } from "./features/reference/ReferenceTab"; +import { SkillsTab } from "./features/skills/SkillsTab"; +import { TreeTab } from "./features/tree/TreeTab"; +import { UpgradesTab } from "./features/upgrades/UpgradesTab"; + +const TABS: { id: string; label: string; icon: ReactNode }[] = [ + { id: "tree", label: "Tree", icon: }, + { id: "skills", label: "Skills", icon: }, + { id: "items", label: "Items", icon: }, + { id: "config", label: "Config", icon: }, + { id: "upgrades", label: "Upgrades", icon: }, + { id: "reference", label: "Guide", icon: }, + { id: "ai", label: "AI / MCP", icon: }, +]; + +function initialTab() { + const hash = window.location.hash.slice(1); + return TABS.some((t) => t.id === hash) ? hash : "tree"; +} + +export default function App() { + const revision = useLiveRevision(); + const overview = useOverview(); + const [tab, setTabState] = useState(initialTab); + useEffect(() => { + const onHash = () => setTabState(initialTab()); + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, []); + const setTab = (id: string) => { + setTabState(id); + history.replaceState(null, "", `#${id}`); + }; + + return ( +
+
+ {overview.error ?
: null} +
+ +
+ +
+ {tab === "tree" && } + {tab === "skills" && } + {tab === "items" && } + {tab === "config" && } + {tab === "upgrades" && } + {tab === "reference" && } + {tab === "ai" && } +
+
+
+
+ ); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000000..6732f5260f --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,34 @@ +export class ApiError extends Error { + constructor(message: string, public status: number, public code?: string) { + super(message); + } +} + +async function request(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`/api${path}`, { + method, + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = typeof data?.error === "string" ? data.error : typeof data?.detail === "string" ? data.detail : res.statusText; + throw new ApiError(msg, res.status, data?.code); + } + return data as T; +} + +export const api = { + get: (path: string) => request("GET", path), + post: (path: string, body: unknown = {}) => request("POST", path, body), + patch: (path: string, body: unknown) => request("PATCH", path, body), + put: (path: string, body: unknown) => request("PUT", path, body), + del: (path: string) => request("DELETE", path), +}; + +export const qs = (params: Record) => { + const s = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") s.set(k, String(v)); + const str = s.toString(); + return str ? `?${str}` : ""; +}; diff --git a/web/src/api/hooks.ts b/web/src/api/hooks.ts new file mode 100644 index 0000000000..27eccf8cba --- /dev/null +++ b/web/src/api/hooks.ts @@ -0,0 +1,61 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { api } from "./client"; +import type { Advice, Budget, ClassInfo, ConfigOption, Overview, ReferenceCompare, ReferenceSummary, SkillGroup, SlotItem, TreeLayout } from "./types"; + +/** Poll the server revision; when an agent (MCP) or another tab changes the build, refetch everything. */ +export function useLiveRevision() { + const qc = useQueryClient(); + const last = useRef(null); + const { data } = useQuery({ + queryKey: ["revision"], + queryFn: () => api.get<{ revision: number }>("/revision"), + refetchInterval: 1500, + }); + useEffect(() => { + if (!data) return; + if (last.current !== null && last.current !== data.revision) { + qc.invalidateQueries({ predicate: (q) => q.queryKey[0] !== "revision" && q.queryKey[0] !== "static" }); + } + last.current = data.revision; + }, [data, qc]); + return data?.revision; +} + +export const useOverview = () => useQuery({ queryKey: ["build"], queryFn: () => api.get("/build") }); +export const useSkills = () => useQuery({ queryKey: ["skills"], queryFn: () => api.get("/skills") }); +export const useItems = () => + useQuery({ queryKey: ["items"], queryFn: () => api.get("/items?includeEmpty=true") }); +export const useSlots = () => + useQuery({ queryKey: ["static", "slots"], queryFn: () => api.get("/items/slots"), staleTime: Infinity }); +export const useClasses = () => + useQuery({ queryKey: ["static", "classes"], queryFn: () => api.get("/classes"), staleTime: Infinity }); +export const useTreeLayout = () => useQuery({ queryKey: ["tree-layout"], queryFn: () => api.get("/tree/layout") }); +export const useConfig = (all: boolean) => + useQuery({ queryKey: ["config", all], queryFn: () => api.get(`/config${all ? "?all=true" : ""}`) }); + +/** A mutation that refreshes all build data afterwards. */ +export function useBuildMutation(fn: (vars: TVars) => Promise) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => qc.invalidateQueries({ predicate: (q) => q.queryKey[0] !== "static" }), + }); +} + +/** The loaded reference guide (.build), or null. */ +export const useReference = () => + useQuery({ queryKey: ["reference"], queryFn: () => api.get("/reference") }); +export const useReferenceCompare = (enabled: boolean, level: number | null = null) => + useQuery({ + queryKey: ["reference-compare", level], + queryFn: () => api.get(`/reference/compare${level ? `?level=${level}` : ""}`), + enabled, + }); + +/** Next point / point to move / attribute advice for the live build (follows the reference guide if loaded). */ +export const useAdvice = (metric: Advice["metric"], enabled = true) => + useQuery({ queryKey: ["advice", metric], queryFn: () => api.get(`/advisor?metric=${metric}`), enabled }); + +/** Spirit/totem budget and main-skill DPS across all totems. */ +export const useBudget = () => useQuery({ queryKey: ["budget"], queryFn: () => api.get("/budget") }); diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000000..6f9822b3a2 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,589 @@ +export interface BuildInfo { + name?: string; + className: string; + ascendClassName?: string; + level: number; + levelAutoMode: boolean; + treeVersion: string; + pointsUsed: number; + ascendancyPointsUsed: number; + mainSocketGroup?: number; + mainSkill?: string; +} + +export interface StatRow { + stat: string; + label: string; + value: number; + formatted: string; + overCap?: number; + lowerIsBetter?: boolean; + warning?: string; +} + +export interface StatSummary { + player: StatRow[]; + minion?: StatRow[]; + skillDPS: { name: string; dps: number; count: number; source?: string }[]; +} + +export interface Gem { + name: string; + level: number; + quality: number; + enabled: boolean; + support: boolean; + recognised: boolean; +} + +export interface SkillGroup { + index: number; + label?: string; + displayLabel?: string; + slot?: string; + enabled: boolean; + isMain: boolean; + gems: Gem[]; + activeSkills: { index: number; name: string; isMain: boolean }[]; + warnings?: string[]; +} + +export interface ModLine { + text: string; + kind: "enchant" | "rune" | "implicit" | "explicit"; + supported: boolean; + crafted?: boolean; +} + +export interface Item { + id: number; + name: string; + title?: string; + baseName: string; + type: string; + rarity: "NORMAL" | "MAGIC" | "RARE" | "UNIQUE" | "RELIC"; + corrupted?: boolean; + itemLevel?: number; + quality?: number; + mods?: ModLine[]; + text?: string; +} + +export interface SlotItem { + slot: string; + label?: string; + jewelNodeId?: number; + item?: Item; +} + +export interface Overview { + info: BuildInfo; + stats: StatSummary; + skills: SkillGroup[]; + items: { slot: string; name: string; rarity: string }[]; + config: Record; + revision: number; +} + +export interface StatChange { + stat: string; + label: string; + before: number; + after: number; + delta: number; + percent?: number; + better: boolean; +} + +export interface Comparison { + changes: StatChange[]; + pointDelta?: number; + slot?: string; + item?: string; + budget?: BudgetCompare; +} + +/** Spirit and totems for one calculation (PoB reports totem DPS per totem; these count real totems). */ +export interface BudgetSummary { + spirit: number; + reserved: number; + unreserved: number; + totemCap?: number; + totems?: number; + totemsAffordable?: number; + totemCost?: number; + spiritLimited?: boolean; + spiritShort?: number; + perTotemDps?: number; +} + +export interface BudgetCompare { + before: BudgetSummary; + after: BudgetSummary; + effectiveBefore: number; + effectiveAfter: number; + warnings: string[]; +} + +export interface Resource { + key: string; + label: string; + total: number; + unreserved?: number; + regen?: number; + costPerSecond?: number; +} + +export interface Budget extends BudgetSummary { + effectiveDps: number; + free: number; + ancestralBond?: boolean; + reservations: { name: string; spirit: number; totem?: boolean; count?: number }[]; + skill?: string; + ehp: number; + life: number; + es: number; + resources: Resource[]; + defence: { + armour: number; + evasion: number; + block: number; + lifeRegen: number; + fire: number; + cold: number; + lightning: number; + chaos: number; + maxHit: number; + }; +} + +export interface ItemCheck { + item: string; + slot: string; + replaces?: string; + verdict: "upgrade" | "sidegrade" | "downgrade"; + reasons: string[]; + warnings: string[]; + budget: { before: BudgetSummary; after: BudgetSummary }; + lostSkills: string[]; + skills: { group: number; name: string; isMain?: boolean; before: number; after: number; percent?: number; totemsBefore?: number; totemsAfter?: number; lost?: boolean }[]; + defence: Record; + caveat: string; +} + +export interface TreeNodeInfo { + id: number; + name: string; + type: string; + ascendancy?: string; + allocated: boolean; + stats?: string[]; + pathLength?: number; +} + +export interface LayoutNode { + id: number; + x: number; + y: number; + type: string; + name?: string; + ascendancy?: string; + /** Artwork asset names (see /tree-art//manifest.json) and draw radii in tree units. */ + icon?: string; + r?: number; + frame?: [string, string, string]; // unallocated, can-allocate, allocated + fr?: number; + effect?: string; + er?: number; + options?: { name: string; icon: string }[]; +} + +/** [from, to] for a straight connection, or [from, to, cx, cy, r] for one following an orbit arc. */ +export type LayoutEdge = [number, number] | [number, number, number, number, number]; + +export interface LayoutImage { + x: number; + y: number; + image: string; + r?: number; +} + +export interface LayoutClass { + id: number; + name: string; + startNode?: number; + image: string; + ascendancyImages: Record; + x: number; + y: number; + w: number; + h: number; + treeW?: number; + activeW?: number; +} + +export interface LayoutAscendancy { + name: string; + className: string; + image: string; + x: number; + y: number; + w: number; + h: number; + replace?: boolean; + replaceBy?: string; +} + +export interface TreeLayout { + treeVersion: string; + nodes: LayoutNode[]; + edges: LayoutEdge[]; + decorations?: LayoutImage[]; + classes?: LayoutClass[]; + ascendancies?: LayoutAscendancy[]; + allocated: number[]; + attributes?: Record; + jewels?: Record; + className?: string; + ascendClassName?: string; +} + +export interface ConfigOption { + var: string; + type: "check" | "count" | "integer" | "float" | "list"; + label: string; + section?: string; + value: unknown; + default: unknown; + shown: boolean; + choices?: { value: string | number; label: string }[]; +} + +export interface ClassInfo { + id: number; + name: string; + ascendancies: string[]; +} + +// -- reference build (PoE2 BuildPlanner .build guide) -------------------------------------------------- + +export interface ReferenceSummary { + name?: string; + author?: string; + link?: string; + ascendancy?: string; + passives: number; + skills: number; + items: number; +} + +export interface RefNode { + id: number; + name?: string; + type: string; + ascendancy?: string; +} + +export interface ReferenceSkill { + name: string; + note?: string; + found: boolean; + group?: number; + enabled?: boolean; + supports: string[]; + missingSupports: string[]; + extraSupports: string[]; + tierDifferences: { reference: string; current: string }[]; +} + +export interface ReferenceItem { + slot: string; + uniqueName?: string; + note?: string; + equipped?: { name: string; title?: string; baseName?: string; rarity?: string }; + match?: boolean; +} + +export interface ReferenceCompare { + name?: string; + author?: string; + link?: string; + description?: string; + level: number; + stages: { from: number; to: number }[]; + ascendancy?: { internalId: string; className: string; name: string }; + current: { className?: string; ascendClassName?: string; points: number; ascendancyPoints: number }; + passives: { + referencePoints: number; + referenceAscendancyPoints: number; + matched: number; + missing: RefNode[]; + extra: RefNode[]; + weaponSetMismatch: number; + unknown: string[]; + }; + skills: ReferenceSkill[]; + items: ReferenceItem[]; + unknownGems: string[]; +} + +export interface ImpactSummary { + points: number; + dpsPercent: number; + ehpPercent: number; + lifeDelta: number; + esDelta: number; +} + +export interface ImpactStep extends ImpactSummary { + id: number; + name: string; + type: string; + ascendancy?: string; + path?: number[]; + score?: number; +} + +export interface ReferenceImpact { + level: number; + base: { dps: number; ehp: number; life: number; es: number }; + fullTree: ImpactSummary & { added: number; removed: number; changes: StatChange[] }; + steps: ImpactStep[]; + extras: ImpactStep[]; +} + +// -- advisor --------------------------------------------------------------------------------------------- + +export interface AdviceStep extends ImpactSummary { + score: number; + perPoint: number; + target: RefNode; + first: RefNode; + /** Allocation order: path[0] touches your tree, the last id is the target. */ + path: number[]; + reason?: string; + inGuide: boolean; +} + +export interface AdviceRefund extends ImpactSummary { + score: number; + node: RefNode; + reason: string; + notInGuide: boolean; +} + +export interface AttributeAdvice { + id: number; + current: string; + recommended: string; + weaponSet: number; + reason: string; + dpsPercent?: number; + ehpPercent?: number; + lifeDelta?: number; + esDelta?: number; + blockedBy?: string; +} + +export interface Advice { + mode: "guide" | "free"; + guide?: { name?: string; level: number }; + metric: "balanced" | "dps" | "ehp"; + next?: AdviceStep; + steps: AdviceStep[]; + refunds: AdviceRefund[]; + move?: { refund: AdviceRefund; allocate: AdviceStep; netPerPoint: number; reason: string }; + attributes: { totals: Record; nodes: AttributeAdvice[]; changes: number }; +} + +export interface SocketedJewel { + name: string; + title?: string; + baseName?: string; + rarity?: string; + mods?: ModLine[]; +} + +// -- gear, shopping and gems advisors --------------------------------------------------------------------- + +export interface Gain { + dpsPercent: number; + ehpPercent: number; + lifeDelta: number; + esDelta: number; + score: number; + /** Totems you could keep up, and Spirit, compared with now (only present when they change). */ + totemsDelta?: number; + spiritDelta?: number; +} + +export interface GearMod extends Gain { + text: string; + kind: "implicit" | "explicit"; + dead?: boolean; + unmeasured?: boolean; + requirement?: string; +} + +export interface GearSlotRef { + slot: string; + jewel?: boolean; + nodeId?: number; + item?: { name: string; rarity: string; baseName: string }; +} + +export interface GearSlot { + slot: string; + metric: string; + jewel?: boolean; + item?: { name: string; rarity: string; baseName: string; title?: string }; + modsWorth?: Gain; + mods?: GearMod[]; + deadMods?: number; + lookFor?: { id: string; text: string; value: number; percent: number }[]; + currentPercent?: number; + idealPercent?: number; + headroom?: number; + tradeUrl?: string; + league?: string; + unsupported?: boolean; + testBase?: string; +} + +export interface UniqueSuggestion extends Gain { + name: string; + baseName?: string; + slot: string; + levelRequirement?: number; + price?: string; + priceDivine?: number; + listings?: number; + gainPerDivine?: number; + tradeUrl?: string; +} + +export interface UniquesAdvice { + league?: string; + rates: Record; + slots: { slot: string; items: UniqueSuggestion[] }[]; +} + +export interface GemAdviceGem { + index: number; + name: string; + support: boolean; + level?: number; + quality?: number; + enabled: boolean; + problems: string[]; + worth?: Gain; + levelUp?: Gain; + qualityUp?: Gain; + noEffect?: boolean; +} + +export interface GemAdviceGroup { + index: number; + label: string; + skills: string[]; + isMain: boolean; + measuredOn: "skill" | "build"; + gems: GemAdviceGem[]; + swap?: { replace: string; index: number; with: string; gain: Gain; alternatives: (Gain & { name: string })[] }; + lineage?: Gain & { name: string }; +} + +export interface GemUpgrade { + kind: "level" | "quality" | "swap"; + group: number; + index: number; + name: string; + from?: number; + to?: number; + with?: string; + gain: Gain; + text: string; +} + +export interface GemAdvice { + metric: string; + groups: GemAdviceGroup[]; + upgrades: GemUpgrade[]; + problems: { group: number; index: number; name: string; problem: string }[]; +} + +export interface SourceInfo { + source?: string; + character: boolean; + league?: string; + loadedAt?: number; +} + +// -- sockets (runes, soul cores, idols) ------------------------------------------------------------------- + +export interface SocketDelta { + score: number; + dpsPercent: number; + ehpPercent: number; + totemsDelta?: number; + spiritDelta?: number; +} + +export interface SocketOption extends SocketDelta { + name: string; + type?: string; + limitId?: string; + lines: string[]; + bonded?: string[]; + /** Lines PoB does not model (their effect isn't in the numbers). */ + unmodelled?: string[]; + /** Only legal after freeing a character-wide limit held in these sockets. */ + blockedBy?: { slot: string; socket: number; name: string }[]; +} + +export interface SocketWorth extends SocketOption { + dead?: boolean; + socketBound?: boolean; +} + +export interface SocketInfo { + index: number; + current: string; + worth?: SocketWorth; + legalOptions?: number; + bestGain?: number; + alternatives?: SocketOption[]; +} + +export interface SocketSlot { + slot: string; + item: string; + corrupted?: boolean; + unique?: boolean; + sockets: SocketInfo[]; +} + +export interface SocketAdvice { + metric: string; + note: string; + slots: SocketSlot[]; +} + +export interface SocketPlanStep extends SocketDelta { + slot: string; + socket: number; + from: string; + to: string; + lines: string[]; + unmodelled?: string[]; + pairedWith?: string; + losesUnmodelled?: string[]; +} + +export interface SocketPlan { + metric: string; + steps: SocketPlanStep[]; + total?: SocketDelta; + warnings: string[]; + note: string; +} diff --git a/web/src/components/BudgetCard.tsx b/web/src/components/BudgetCard.tsx new file mode 100644 index 0000000000..cdbc3fa8f1 --- /dev/null +++ b/web/src/components/BudgetCard.tsx @@ -0,0 +1,273 @@ +import { AlertTriangle, Loader2, Shield, Swords } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import type { Budget, Resource } from "../api/types"; +import { useBudget } from "../api/hooks"; +import { fmtCompact, fmtNum } from "./format"; + +const RESOURCE: Record = { + Mana: { bar: "bg-mana", text: "text-mana" }, + Spirit: { bar: "bg-gold", text: "text-gold-soft" }, + Rage: { bar: "bg-bad", text: "text-bad" }, + Ward: { bar: "bg-es", text: "text-es" }, + Life: { bar: "bg-life", text: "text-life" }, + ES: { bar: "bg-es", text: "text-es" }, +}; + +/** Change chips for the headline numbers after a recalculation; they fade after a few seconds. */ +function useChanges(b?: Budget) { + const last = useRef<{ dps: number; ehp: number } | null>(null); + const [chips, setChips] = useState<{ dps?: number; ehp?: number }>({}); + useEffect(() => { + if (!b) return; + const prev = last.current; + last.current = { dps: b.effectiveDps, ehp: b.ehp }; + if (!prev) return; + const pct = (a: number, z: number) => (a > 0 ? ((z - a) / a) * 100 : 0); + const next = { dps: pct(prev.dps, b.effectiveDps), ehp: pct(prev.ehp, b.ehp) }; + // Ignore no-ops and wholesale changes (another build imported). + const keep = (v: number) => (Math.abs(v) >= 0.05 && Math.abs(v) < 300 ? v : undefined); + const shown = { dps: keep(next.dps), ehp: keep(next.ehp) }; + if (shown.dps === undefined && shown.ehp === undefined) return; + setChips(shown); + const t = setTimeout(() => setChips({}), 5000); + return () => clearTimeout(t); + }, [b]); + return chips; +} + +/** Sidebar headline: DPS and EHP large, then the resources this character has (Mana, Spirit, Rage, Ward...). + * Refetched whenever the build changes (useLiveRevision invalidates every query). */ +export function BudgetCard() { + const { data: b, isFetching } = useBudget(); + const chips = useChanges(b); + if (!b) return ; + const totems = b.totems !== undefined; + return ( +
+ {isFetching && } +
+ }> + } + label="DPS" + value={b.effectiveDps} + chip={chips.dps} + className="text-gold-soft" + sub={totems ? <>{b.totems} totem{b.totems === 1 ? "" : "s"} × {fmtCompact(b.perTotemDps ?? 0)} : b.skill} + /> + + }> + } + label="EHP" + value={b.ehp} + chip={chips.ehp} + className="text-good" + sub={ + <> + {fmtCompact(b.life)} Life + {b.es > 0 && <> · {fmtCompact(b.es)} ES} + + } + /> + +
+
+ {b.resources.map((r) => ( + + ))} + {b.spirit > 0 && } +
+ {totems && b.spiritLimited && ( +

+ + {b.totems} of {b.totemCap} totems: each reserves {fmtNum(b.totemCost ?? 0)} Spirit.{" "} + {b.totemCost ? `${fmtNum(b.totemCost - Math.max(0, b.free))} more for the next one.` : ""} +

+ )} +
+ ); +} + +function Skeleton() { + return ( +
+
+ {[0, 1].map((i) => ( +
+
+
+
+
+ ))} +
+
+ {[0, 1].map((i) => ( +
+
+
+
+ ))} +
+
+ ); +} + +/** Hover zone whose panel opens below the card, full card width (the sidebar clips anything wider). */ +function Hover({ children, panel }: { children: ReactNode; panel: ReactNode }) { + return ( +
+ {children} +
+ {panel} +
+
+ ); +} + +function Chip({ pct }: { pct?: number }) { + if (pct === undefined) return null; + const up = pct > 0; + return ( + + {up ? "+" : ""}{pct.toFixed(1)}% + + ); +} + +function Headline({ icon, label, value, chip, className, sub }: { icon: ReactNode; label: string; value: number; chip?: number; className: string; sub?: ReactNode }) { + return ( +
+
+ {icon}{label} + +
+
{fmtCompact(value)}
+ {sub &&
{sub}
} +
+ ); +} + +function Bar({ pct, color }: { pct: number; color: string }) { + return ( +
+
+
+ ); +} + +function Sustain({ regen, cost }: { regen?: number; cost?: number }) { + if (regen === undefined && cost === undefined) return null; + if (cost === undefined) return
+{fmtNum(regen ?? 0)}/s regen
; + const net = (regen ?? 0) - cost; + return ( +
+ +{fmtNum(regen ?? 0)} regen −{fmtNum(cost)} cost + net {net >= 0 ? "+" : "−"}{fmtNum(Math.abs(net))}/s +
+ ); +} + +function ResourceRow({ r }: { r: Resource }) { + const style = RESOURCE[r.key] ?? { bar: "bg-ink-400", text: "text-ink-200" }; + const avail = r.unreserved ?? r.total; + const reserved = avail < r.total; + return ( +
+
+ {r.label} + + {reserved ? <>{fmtNum(avail)} / {fmtNum(r.total)} : fmtNum(r.total)} + +
+ 0 ? (avail / r.total) * 100 : 0} color={style.bar} /> + +
+ ); +} + +function SpiritRow({ b }: { b: Budget }) { + const used = b.spirit - b.free; + return ( + }> +
+ Spirit + + {fmtNum(used)} / {fmtNum(b.spirit)} · {fmtNum(b.free)} free + +
+ 0 ? (used / b.spirit) * 100 : 0} color={b.free < 0 ? "bg-bad" : "bg-gold"} /> +
+ ); +} + +function Rows({ rows }: { rows: [ReactNode, ReactNode, string?][] }) { + return ( +
+ {rows.map(([k, v, cls], i) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ); +} + +function DpsDetail({ b }: { b: Budget }) { + const rows: [ReactNode, ReactNode, string?][] = [["Main skill", b.skill ?? "—"]]; + if (b.totems !== undefined) { + rows.push(["Per totem", fmtNum(b.perTotemDps ?? 0)]); + rows.push(["Totems out", `${b.totems} / ${b.totemCap}`, b.spiritLimited ? "text-rare" : undefined]); + } + rows.push(["Total DPS", fmtNum(b.effectiveDps), "text-gold-soft"]); + return ( + <> + + {b.totems !== undefined &&

PoB shows totem DPS per totem; this counts every totem your Spirit keeps up.

} + + ); +} + +/** Element colour when capped, amber when under the 75% cap, red when negative. */ +function Res({ v, cls, name }: { v: number; cls: string; name: string }) { + const color = v < 0 ? "text-bad" : v < 75 ? "text-rare" : cls; + return {Math.round(v)}%; +} + +function EhpDetail({ b }: { b: Budget }) { + const d = b.defence; + const rows: [ReactNode, ReactNode, string?][] = [ + ["Life", fmtNum(b.life), "text-life"], + ...(b.es > 0 ? [["Energy Shield", fmtNum(b.es), "text-es"] as [ReactNode, ReactNode, string]] : []), + ["Armour", fmtNum(d.armour)], + ["Evasion", fmtNum(d.evasion)], + ...(d.block > 0 ? [["Block", `${Math.round(d.block)}%`] as [ReactNode, ReactNode]] : []), + ["Life regen", `${fmtNum(d.lifeRegen)}/s`], + ["Max physical hit", fmtNum(d.maxHit)], + ]; + return ( + <> + +
+ Res + + + + +
+ + ); +} + +function SpiritDetail({ b }: { b: Budget }) { + return ( + <> + [`${r.name}${r.count ? ` ×${r.count}` : ""}`, fmtNum(r.spirit)] as [ReactNode, ReactNode])} /> +
+ Free + {fmtNum(b.free)} / {fmtNum(b.spirit)} +
+ + ); +} diff --git a/web/src/components/BuildDialogs.tsx b/web/src/components/BuildDialogs.tsx new file mode 100644 index 0000000000..cedc54f085 --- /dev/null +++ b/web/src/components/BuildDialogs.tsx @@ -0,0 +1,91 @@ +import { Check, Copy } from "lucide-react"; +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import { useBuildMutation, useClasses } from "../api/hooks"; +import type { BuildInfo } from "../api/types"; +import { ErrorText } from "./ErrorText"; +import { Modal } from "./Modal"; + +export function ImportDialog({ onClose }: { onClose: () => void }) { + const [source, setSource] = useState(""); + const m = useBuildMutation((s: string) => api.post("/build/import", { source: s })); + return ( + + +